use std::collections::HashMap;
use crate::helpers::string_helper::collapse_whitespace;
use crate::models::{FullSyllableInfo, LineInfo, LyricsAlignment, SyllableInfo, SyllableItem};
const LABEL_SEPARATORS: [char; 5] = ['/', '&', ',', ',', '、'];
const JOINT_LABELS: [&str; 5] = ["both", "all", "合唱", "齐唱", "合"];
const OPEN_MARKS: [char; 7] = [',', ',', '、', ';', ';', ':', ':'];
const CONTINUATION_GAP_MS: i32 = 250;
const CLOSING_MARKS: [char; 13] = [
'.', '。', '!', '!', '?', '?', '…', '"', '“', '”', ')', ')', '】',
];
const ECHO_GAP_MS: i32 = 2_000;
pub fn apply_speaker_labels(lines: &mut Vec<LineInfo>, artists: &[String]) {
let mut current_alignment = LyricsAlignment::Unspecified;
lines.retain_mut(|line| {
let text = line.text_from_any();
if let Some((alignment, content_start)) = speaker_label(&text, artists) {
current_alignment = alignment;
strip_speaker_label(line, content_start, text[..content_start].chars().count());
}
if current_alignment != LyricsAlignment::Unspecified {
set_alignment(line, current_alignment);
}
!is_blank(line)
});
}
fn speaker_label(text: &str, artists: &[String]) -> Option<(LyricsAlignment, usize)> {
let (colon_start, colon) = text
.char_indices()
.find(|(_, character)| matches!(character, ':' | ':'))?;
let label = text[..colon_start].trim();
if label.is_empty() {
return None;
}
let after_colon = colon_start + colon.len_utf8();
let content = text[after_colon..].trim_start();
let content_start = text.len() - content.len();
if is_joint_label(label) {
return Some((LyricsAlignment::Left, content_start));
}
let position = |name: &str| {
let name = normalize_artist(name);
artists
.iter()
.position(|artist| !name.is_empty() && name == normalize_artist(artist))
};
let leading = position(label).or_else(|| label.split(LABEL_SEPARATORS).find_map(position))?;
let alignment = if leading == 0 {
LyricsAlignment::Left
} else {
LyricsAlignment::Right
};
Some((alignment, content_start))
}
fn is_joint_label(label: &str) -> bool {
JOINT_LABELS.contains(&normalize_artist(label).as_str())
}
fn strip_speaker_label(line: &mut LineInfo, content_start: usize, characters: usize) {
match line {
LineInfo::Line { text, .. } | LineInfo::FullLine { text, .. } => {
text.drain(..content_start);
}
LineInfo::Syllable { syllables, .. } | LineInfo::FullSyllable { syllables, .. } => {
strip_leading_characters(syllables, characters);
}
}
}
fn strip_leading_characters(items: &mut Vec<SyllableItem>, characters: usize) {
let mut remaining = characters;
items.retain_mut(|item| {
if remaining == 0 {
return true;
}
let count = item_characters(item);
if remaining >= count {
remaining -= count;
return false;
}
strip_item_leading(item, remaining);
remaining = 0;
true
});
}
fn strip_item_leading(item: &mut SyllableItem, characters: usize) {
let mut remaining = characters;
for part in item.parts_mut() {
if remaining == 0 {
break;
}
let count = part.text.chars().count();
if remaining >= count {
part.text.clear();
remaining -= count;
continue;
}
let byte_offset = part
.text
.char_indices()
.nth(remaining)
.map_or(part.text.len(), |(index, _)| index);
part.text.drain(..byte_offset);
remaining = 0;
}
}
fn set_alignment(line: &mut LineInfo, alignment: LyricsAlignment) {
line.set_alignment(alignment);
if let Some(sub_line) = line.sub_line_mut() {
sub_line.set_alignment(alignment);
}
}
fn is_blank(line: &LineInfo) -> bool {
match line {
LineInfo::Line { text, .. } | LineInfo::FullLine { text, .. } => text.trim().is_empty(),
LineInfo::Syllable { .. } | LineInfo::FullSyllable { .. } => {
!line.syllables().unwrap_or_default().iter().any(has_text)
}
}
}
fn normalize_artist(value: &str) -> String {
let cleaned: String = value
.to_lowercase()
.chars()
.map(|character| {
if character.is_alphanumeric() {
character
} else {
' '
}
})
.collect();
collapse_whitespace(&cleaned)
}
pub fn split_background_vocals(lines: &mut [LineInfo]) {
for line in lines {
split_background_vocal(line);
}
}
fn split_background_vocal(line: &mut LineInfo) {
if line.sub_line().is_some() || !is_word_timed(line) {
return;
}
let text = line.text_from_any();
let Some(phrase) = bracketed_phrase_in_line(&text) else {
return;
};
let before = &text[..phrase.open];
let after = &text[phrase.close..];
let head_text = join_around(before, after);
if head_text.is_empty() {
return;
}
let mut head = take_syllables(line);
let mut enclosed = split_syllables_at(&mut head, before.chars().count());
let trailing = split_syllables_at(
&mut enclosed,
text[phrase.open..phrase.close].chars().count(),
);
let background = unwrap_syllable_brackets(enclosed);
if trailing.is_empty() {
trim_end_of_last_word(&mut head);
} else {
join_words(&mut head, trailing);
}
let mut sub_line = LineInfo::new_syllable(background);
sub_line.set_alignment(line.alignment());
line.set_sub_line(Some(Box::new(sub_line)));
set_syllables(line, head);
}
fn join_around(before: &str, after: &str) -> String {
match (before.trim_end(), after.trim_start()) {
("", after) => after.to_string(),
(before, "") => before.to_string(),
(before, after) => format!("{before} {after}"),
}
}
fn join_words(head: &mut Vec<SyllableItem>, mut trailing: Vec<SyllableItem>) {
let separators = trailing
.iter()
.take_while(|item| !item.parts().iter().any(|part| !part.text.trim().is_empty()))
.count();
trailing.drain(..separators);
trim_start_of_first_word(&mut trailing);
trim_end_of_last_word(head);
continue_words(head, trailing);
}
fn unwrap_syllable_brackets(mut items: Vec<SyllableItem>) -> Vec<SyllableItem> {
if let Some(part) = items.first_mut().and_then(first_part_mut) {
let mut rest = part.text.trim_start();
rest = rest.strip_prefix(['(', '(']).unwrap_or(rest);
rest = rest.trim_start();
let skip = part.text.len() - rest.len();
part.text.drain(..skip);
}
if let Some(part) = items.last_mut().and_then(last_part_mut) {
let mut rest = part.text.trim_end();
rest = rest.strip_suffix([')', ')']).unwrap_or(rest);
rest = rest.trim_end();
part.text.truncate(rest.len());
}
items.retain(|item| item.parts().iter().any(|part| !part.text.is_empty()));
items
}
pub fn fold_bracketed_echoes(lines: &mut Vec<LineInfo>) {
let mut index = 1;
while index < lines.len() {
let rows = if lines[index - 1].sub_line().is_none() {
echo_rows(lines, index)
} else {
0
};
if rows == 0 {
index += 1;
continue;
}
let echo: Vec<LineInfo> = lines.drain(index..index + rows).collect();
let alignment = lines[index - 1].alignment();
let mut background = echo_background(echo);
background.set_alignment(alignment);
lines[index - 1].set_sub_line(Some(Box::new(background)));
}
}
fn echo_rows(lines: &[LineInfo], index: usize) -> usize {
let parent = &lines[index - 1];
let Some(rows) = bracketed_phrase(lines, index) else {
return 0;
};
let phrase = &lines[index..index + rows];
if !phrase_text(phrase).chars().any(char::is_alphanumeric) {
return 0;
}
let answers = phrase[0].start_time().is_some_and(|start| {
start >= parent.start_time().unwrap_or_default()
&& start <= sung_until_ms(parent).saturating_add(ECHO_GAP_MS)
});
let back_to_back = phrase.windows(2).all(|rows| {
let (Some(start), Some(next)) = (rows[1].start_time(), rows[0].start_time()) else {
return false;
};
start <= sung_until_ms(&rows[0]).saturating_add(ECHO_GAP_MS) && next <= start
});
if answers && back_to_back { rows } else { 0 }
}
fn bracketed_phrase(lines: &[LineInfo], index: usize) -> Option<usize> {
if !lines[index]
.text_from_any()
.trim_start()
.starts_with(['(', '('])
{
return None;
}
let mut depth = 0_usize;
for (offset, line) in lines[index..].iter().enumerate() {
for character in line.text_from_any().chars() {
match character {
'(' | '(' => depth += 1,
')' | ')' => depth = depth.saturating_sub(1),
_ => {}
}
}
if depth == 0 {
return line
.text_from_any()
.trim_end()
.ends_with([')', ')'])
.then_some(offset + 1);
}
}
None
}
fn phrase_text(rows: &[LineInfo]) -> String {
rows.iter()
.enumerate()
.map(|(offset, row)| {
let text = row.text_from_any();
let mut piece = text.trim();
if offset == 0 {
piece = piece.strip_prefix(['(', '(']).unwrap_or(piece);
}
if offset + 1 == rows.len() {
piece = piece.strip_suffix([')', ')']).unwrap_or(piece);
}
piece.trim().to_string()
})
.collect::<Vec<_>>()
.join(" ")
}
fn echo_background(rows: Vec<LineInfo>) -> LineInfo {
let mut rows = rows;
let mut syllables = Vec::new();
for row in rows.iter_mut() {
continue_words(
&mut syllables,
unwrap_syllable_brackets(take_syllables(row)),
);
}
let text = phrase_text(&rows);
let translations = echo_translations(&rows);
if syllables.is_empty() {
let start = rows.first().and_then(|row| row.start_time());
let end = rows.last().and_then(|row| row.end_time());
return line_with_text(text, start, end, translations);
}
line_with_syllables(syllables, translations)
}
fn echo_translations(rows: &[LineInfo]) -> HashMap<String, String> {
let mut pieces: HashMap<String, Vec<String>> = HashMap::new();
for row in rows {
if let Some(translations) = row.translations() {
for (language, text) in translations {
pieces
.entry(language.clone())
.or_default()
.push(unwrap_brackets(text));
}
}
}
pieces
.into_iter()
.filter_map(|(language, pieces)| join_pieces(pieces).map(|joined| (language, joined)))
.collect()
}
pub fn unwrap_brackets(text: &str) -> String {
let trimmed = text.trim();
let Some(open) = trimmed.chars().next() else {
return String::new();
};
let Some(close) = matching_bracket(open) else {
return trimmed.to_string();
};
let mut depth = 0_usize;
let closed_at = trimmed.char_indices().find_map(|(index, character)| {
if character == open {
depth += 1;
} else if character == close {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
None
});
if closed_at != Some(trimmed.len() - close.len_utf8()) {
return trimmed.to_string();
}
trimmed[open.len_utf8()..trimmed.len() - close.len_utf8()]
.trim()
.to_string()
}
fn matching_bracket(open: char) -> Option<char> {
match open {
'(' => Some(')'),
'(' => Some(')'),
'[' => Some(']'),
'【' => Some('】'),
_ => None,
}
}
struct BracketedPhrase {
open: usize,
close: usize,
}
fn bracketed_phrase_in_line(text: &str) -> Option<BracketedPhrase> {
let mut phrase = None;
let mut open = None;
let mut inner = None;
let mut depth = 0_usize;
for (index, character) in text.char_indices() {
match character {
'(' | '(' => {
if depth == 0 {
open = Some(index);
inner = Some(index + character.len_utf8());
}
depth += 1;
}
')' | ')' => {
if depth == 0 {
continue;
}
depth -= 1;
if depth > 0 {
continue;
}
let (Some(bracket), Some(start)) = (open.take(), inner.take()) else {
continue;
};
if text[start..index].trim().chars().any(char::is_alphanumeric) {
phrase = Some(BracketedPhrase {
open: bracket,
close: index + character.len_utf8(),
});
}
}
_ => {}
}
}
phrase
}
fn split_syllables_at(items: &mut Vec<SyllableItem>, character_index: usize) -> Vec<SyllableItem> {
let mut seen = 0_usize;
for index in 0..items.len() {
if seen == character_index {
return items.split_off(index);
}
let count = item_characters(&items[index]);
if seen + count > character_index {
let divided = divide_item(&mut items[index], character_index - seen);
let mut tail = items.split_off(index + 1);
tail.insert(0, divided);
return tail;
}
seen += count;
}
Vec::new()
}
fn divide_item(item: &mut SyllableItem, character_offset: usize) -> SyllableItem {
match item {
SyllableItem::Syllable(syllable) => {
SyllableItem::Syllable(divide_syllable(syllable, character_offset))
}
SyllableItem::Full(full) => SyllableItem::Full(FullSyllableInfo::new(split_sub_items(
full,
character_offset,
))),
}
}
fn split_sub_items(full: &mut FullSyllableInfo, character_offset: usize) -> Vec<SyllableInfo> {
let mut seen = 0_usize;
for index in 0..full.sub_items().len() {
if seen == character_offset {
return full.sub_items_mut().split_off(index);
}
let count = full.sub_items()[index].text.chars().count();
if seen + count > character_offset {
let divided =
divide_syllable(&mut full.sub_items_mut()[index], character_offset - seen);
let mut tail = full.sub_items_mut().split_off(index + 1);
tail.insert(0, divided);
return tail;
}
seen += count;
}
Vec::new()
}
fn divide_syllable(syllable: &mut SyllableInfo, character_offset: usize) -> SyllableInfo {
let characters = syllable.text.chars().count();
let byte_offset = syllable
.text
.char_indices()
.nth(character_offset)
.map_or(syllable.text.len(), |(index, _)| index);
let span = syllable.end_time.saturating_sub(syllable.start_time);
let boundary = syllable
.start_time
.saturating_add(span.saturating_mul(character_offset as i32) / characters as i32);
let text = syllable.text.split_off(byte_offset);
let divided = SyllableInfo::new(text, boundary, syllable.end_time);
syllable.end_time = boundary;
divided
}
fn continue_words(target: &mut Vec<SyllableItem>, mut words: Vec<SyllableItem>) {
if let Some(part) = target.last_mut().and_then(last_part_mut) {
if !part.text.ends_with(char::is_whitespace) {
part.text.push(' ');
}
}
target.append(&mut words);
}
fn join_pieces(pieces: impl IntoIterator<Item = String>) -> Option<String> {
let text = pieces
.into_iter()
.map(|piece| piece.trim().to_string())
.filter(|piece| !piece.is_empty())
.collect::<Vec<_>>()
.join(" ");
(!text.is_empty()).then_some(text)
}
fn line_with_text(
text: String,
start_time: Option<i32>,
end_time: Option<i32>,
translations: HashMap<String, String>,
) -> LineInfo {
if translations.is_empty() {
LineInfo::new_line(text, start_time, end_time)
} else {
LineInfo::new_full_line(text, start_time, end_time, translations, None)
}
}
fn line_with_syllables(
syllables: Vec<SyllableItem>,
translations: HashMap<String, String>,
) -> LineInfo {
if translations.is_empty() {
LineInfo::new_syllable(syllables)
} else {
LineInfo::new_full_syllable(syllables, translations, None)
}
}
fn take_syllables(line: &mut LineInfo) -> Vec<SyllableItem> {
line.syllables_mut().map(std::mem::take).unwrap_or_default()
}
fn set_syllables(line: &mut LineInfo, items: Vec<SyllableItem>) {
if let Some(syllables) = line.syllables_mut() {
*syllables = items;
}
}
fn sung_until_ms(line: &LineInfo) -> i32 {
if is_word_timed(line) {
let start = line.start_time().unwrap_or_default();
return line
.syllables()
.unwrap_or_default()
.iter()
.map(SyllableItem::end_time)
.max()
.unwrap_or(start)
.max(start);
}
latest_time_ms(line)
}
fn is_word_timed(line: &LineInfo) -> bool {
line.syllables()
.is_some_and(|items| items.iter().any(has_text))
}
fn latest_time_ms(line: &LineInfo) -> i32 {
let start = line.start_time().unwrap_or_default();
let from_syllables = line
.syllables()
.unwrap_or_default()
.iter()
.map(SyllableItem::end_time)
.max()
.unwrap_or(start);
line.end_time()
.unwrap_or(start)
.max(from_syllables)
.max(start)
}
fn has_text(item: &SyllableItem) -> bool {
item.parts().iter().any(|part| !part.text.trim().is_empty())
}
fn item_characters(item: &SyllableItem) -> usize {
item.parts()
.iter()
.map(|part| part.text.chars().count())
.sum()
}
fn first_part_mut(item: &mut SyllableItem) -> Option<&mut SyllableInfo> {
item.parts_mut().first_mut()
}
fn last_part_mut(item: &mut SyllableItem) -> Option<&mut SyllableInfo> {
item.parts_mut().last_mut()
}
fn trim_start_of_first_word(items: &mut [SyllableItem]) {
if let Some(part) = items.first_mut().and_then(first_part_mut) {
let blank = part.text.len() - part.text.trim_start().len();
part.text.drain(..blank);
}
}
fn trim_end_of_last_word(items: &mut [SyllableItem]) {
if let Some(part) = items.last_mut().and_then(last_part_mut) {
let end = part.text.trim_end().len();
part.text.truncate(end);
}
}
pub fn merge_continued_lines(lines: &mut Vec<LineInfo>) {
let mut index = 0;
while index + 1 < lines.len() {
if !continues(&lines[index], &lines[index + 1]) {
index += 1;
continue;
}
let tail = lines.remove(index + 1);
let head = std::mem::replace(&mut lines[index], LineInfo::new_line_simple(String::new()));
lines[index] = join_continuation(head, tail);
}
}
fn continues(first: &LineInfo, tail: &LineInfo) -> bool {
let text = first.text_from_any();
let Some(last) = text.trim_end().chars().next_back() else {
return false;
};
if CLOSING_MARKS.contains(&last) {
return false;
}
let left_open = OPEN_MARKS.contains(&last);
let sentence_goes_on = left_open || has_case(last);
first.alignment() == tail.alignment()
&& !(first.sub_line().is_some() && tail.sub_line().is_some())
&& is_word_timed(first) == is_word_timed(tail)
&& (left_open || follows_flush(first, tail))
&& opens_a_continuation(tail.text_from_any().trim_start(), sentence_goes_on, left_open)
}
fn follows_flush(first: &LineInfo, tail: &LineInfo) -> bool {
let ends_at = sung_until_ms(first);
if ends_at == first.start_time().unwrap_or_default() {
return true;
}
tail.start_time()
.is_some_and(|start| start <= ends_at.saturating_add(CONTINUATION_GAP_MS))
}
fn opens_a_continuation(text: &str, sentence_goes_on: bool, left_open: bool) -> bool {
let mut characters = text.chars();
match characters.next() {
Some(first) if first.is_lowercase() => sentence_goes_on,
Some('I') => left_open && matches!(characters.next(), None | Some(' ' | '\'' | '’')),
_ => false,
}
}
fn has_case(character: char) -> bool {
character.is_uppercase() || character.is_lowercase()
}
fn join_continuation(mut first: LineInfo, mut tail: LineInfo) -> LineInfo {
let end_time = Some(latest_time_ms(&first).max(latest_time_ms(&tail)));
match &mut first {
LineInfo::Line { text, .. } | LineInfo::FullLine { text, .. } => {
*text = format!("{} {}", text.trim_end(), tail.text_from_any().trim_start());
}
LineInfo::Syllable { syllables, .. } | LineInfo::FullSyllable { syllables, .. } => {
continue_words(syllables, take_syllables(&mut tail));
}
}
let tail_translations = take_translations(&mut tail);
if !tail_translations.is_empty() {
let merged = join_translations(take_translations(&mut first), tail_translations);
set_translations(&mut first, merged);
}
if first.sub_line().is_none() {
first.set_sub_line(tail.take_sub_line());
}
set_end_time(&mut first, end_time);
first
}
fn set_translations(line: &mut LineInfo, translations: HashMap<String, String>) {
match line {
LineInfo::FullLine {
translations: existing,
..
}
| LineInfo::FullSyllable {
translations: existing,
..
} => *existing = translations,
other => {
let owned = std::mem::replace(other, LineInfo::new_line_simple(String::new()));
*other = owned.to_full_line(translations, None);
}
}
}
fn join_translations(
mut first: HashMap<String, String>,
tail: HashMap<String, String>,
) -> HashMap<String, String> {
for (language, text) in tail {
match first.get(&language) {
Some(piece) => {
let joined = join_pieces([piece.clone(), text]).unwrap_or_default();
first.insert(language, joined);
}
None => {
first.insert(language, text);
}
}
}
first
}
fn take_translations(line: &mut LineInfo) -> HashMap<String, String> {
line.translations_mut()
.map(std::mem::take)
.unwrap_or_default()
}
fn set_end_time(line: &mut LineInfo, end_time: Option<i32>) {
match line {
LineInfo::Line { end_time: end, .. }
| LineInfo::Syllable { end_time: end, .. }
| LineInfo::FullLine { end_time: end, .. }
| LineInfo::FullSyllable { end_time: end, .. } => *end = end_time,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn syllable(start_time: i32, end_time: i32, text: &str) -> SyllableItem {
SyllableInfo::new(text.to_string(), start_time, end_time).into()
}
fn line(
start_time: i32,
end_time: Option<i32>,
text: &str,
syllables: Vec<SyllableItem>,
) -> LineInfo {
if syllables.is_empty() {
return LineInfo::new_line(text.to_string(), Some(start_time), end_time);
}
let spelled = LineInfo::text_from_syllables(&syllables);
assert_eq!(spelled, text, "测试写的音节要正好拼出这一行的文字");
LineInfo::new_syllable_with_time(syllables, Some(start_time), end_time)
}
fn background_text(line: &LineInfo) -> Option<String> {
line.sub_line().map(LineInfo::text_from_any)
}
fn texts(line: &LineInfo) -> Vec<String> {
line.syllables()
.unwrap_or_default()
.iter()
.map(SyllableItem::text)
.collect()
}
#[test]
fn a_bracketed_tail_becomes_a_background_vocal() {
let mut lines = vec![line(
0,
Some(4_650),
"Ahuh Ahuh (Yea Rihanna)",
vec![
syllable(0, 180, "Ahuh "),
syllable(180, 270, "Ahuh "),
syllable(270, 270, "("),
syllable(270, 3_990, "Yea "),
syllable(3_990, 4_650, "Rihanna"),
syllable(4_650, 4_650, ")"),
],
)];
split_background_vocals(&mut lines);
assert_eq!(lines[0].text_from_any(), "Ahuh Ahuh");
assert_eq!(background_text(&lines[0]).as_deref(), Some("Yea Rihanna"));
assert_eq!(
texts(&lines[0]),
vec!["Ahuh ", "Ahuh"],
"行尾不再留着短语前那个分隔符"
);
let background = lines[0].sub_line().expect("尾巴被切了出来");
assert_eq!(
(background.start_time(), background.end_time()),
(Some(270), Some(4_650)),
"尾巴唱在它自己的词所在的位置上"
);
}
#[test]
fn a_bracketed_tail_inside_a_syllable_is_divided_by_its_characters() {
let mut lines = vec![line(
1_000,
Some(3_000),
"Hold on (ohyeah)",
vec![
syllable(1_000, 1_500, "Hold "),
syllable(1_500, 2_500, "on (ohyeah)"),
],
)];
split_background_vocals(&mut lines);
assert_eq!(lines[0].text_from_any(), "Hold on");
assert_eq!(background_text(&lines[0]).as_deref(), Some("ohyeah"));
assert_eq!(texts(&lines[0]), vec!["Hold ", "on"]);
assert_eq!(
lines[0]
.sub_line()
.map(|background| (background.start_time(), background.end_time())),
Some((Some(1_772), Some(2_500))),
"尾巴留下的那一半从它留下的字符开始"
);
assert_eq!(
lines[0].syllables().unwrap()[1].end_time(),
1_500 + 1_000 * 3 / 11
);
}
fn with_chinese(line: LineInfo, text: &str) -> LineInfo {
line.to_full_line(HashMap::from([("zh".to_string(), text.to_string())]), None)
}
#[test]
fn a_bracketed_phrase_inside_the_line_becomes_a_background_vocal() {
let mut lines = vec![line(
1_849,
Some(5_627),
"I'm in love (we're in love) with a monster",
vec![
syllable(1_849, 2_029, "I'm "),
syllable(2_029, 2_219, "in "),
syllable(2_219, 2_928, "love "),
syllable(2_928, 3_308, "("),
syllable(3_308, 3_488, "we're "),
syllable(3_488, 3_918, "in "),
syllable(3_918, 4_437, "love) "),
syllable(4_437, 4_607, "with "),
syllable(4_607, 4_787, "a "),
syllable(4_787, 5_627, "monster"),
],
)];
split_background_vocals(&mut lines);
assert_eq!(lines[0].text_from_any(), "I'm in love with a monster");
assert_eq!(
texts(&lines[0]),
vec!["I'm ", "in ", "love ", "with ", "a ", "monster"],
"这一行画出来的词是短语两边的词"
);
let background = lines[0].sub_line().expect("短语被切了出来");
assert_eq!(background.text_from_any(), "we're in love");
assert_eq!(
texts(background),
vec!["we're ", "in ", "love"],
"括号不是唱出来的,所以不是短语的词"
);
assert_eq!(
(background.start_time(), background.end_time()),
(Some(3_308), Some(4_350)),
"短语唱在它自己的词所在的位置上,在这一行里面"
);
}
#[test]
fn a_bracketed_phrase_the_line_opens_with_becomes_a_background_vocal() {
let mut lines = vec![line(
0,
Some(2_000),
"(Oh) I love it",
vec![
syllable(0, 300, "(Oh) "),
syllable(300, 700, "I "),
syllable(700, 1_200, "love "),
syllable(1_200, 2_000, "it"),
],
)];
split_background_vocals(&mut lines);
assert_eq!(lines[0].text_from_any(), "I love it");
assert_eq!(texts(&lines[0]), vec!["I ", "love ", "it"]);
assert_eq!(background_text(&lines[0]).as_deref(), Some("Oh"));
}
#[test]
fn a_bracketed_tail_without_words_is_left_alone() {
let mut lines = vec![line(
1_000,
Some(3_000),
"Wait (...)",
vec![
syllable(1_000, 2_000, "Wait "),
syllable(2_000, 3_000, "(...)"),
],
)];
split_background_vocals(&mut lines);
assert_eq!(lines[0].text_from_any(), "Wait (...)");
assert!(lines[0].sub_line().is_none());
}
#[test]
fn a_line_timed_source_keeps_its_brackets() {
let mut lines = vec![line(0, None, "Know the way (My way)", Vec::new())];
split_background_vocals(&mut lines);
assert_eq!(lines[0].text_from_any(), "Know the way (My way)");
assert!(lines[0].sub_line().is_none());
}
#[test]
fn a_wholly_bracketed_line_joins_the_line_it_echoes() {
let mut lines = vec![
line(
1_000,
Some(3_000),
"Know the way",
vec![
syllable(1_000, 2_000, "Know "),
syllable(2_000, 3_000, "the way"),
],
),
line(
3_000,
Some(4_000),
"(My way)",
vec![
syllable(3_000, 3_100, "("),
syllable(3_100, 4_000, "My way)"),
],
),
];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text_from_any(), "Know the way");
assert_eq!(background_text(&lines[0]).as_deref(), Some("My way"));
}
#[test]
fn a_folded_echo_keeps_its_own_translation() {
let mut lines = vec![
with_chinese(
line(
1_000,
Some(3_000),
"Know the way",
vec![syllable(1_000, 3_000, "Know the way")],
),
"要知道方法",
),
with_chinese(
line(
3_000,
Some(4_000),
"(My way)",
vec![syllable(3_000, 4_000, "(My way)")],
),
"(从我身边离开的方法)",
),
];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].chinese_translation(), Some("要知道方法"));
let background = lines[0].sub_line().expect("回声被并了进来");
assert_eq!(background.text_from_any(), "My way");
assert_eq!(
(background.start_time(), background.end_time()),
(Some(3_000), Some(4_000)),
"回声唱在它自己的词所在的位置上,不是它前面那一行唱完的地方"
);
assert_eq!(
texts(background),
vec!["My way"],
"括号不是唱出来的,所以不是回声的词"
);
assert_eq!(
background.chinese_translation(),
Some("从我身边离开的方法"),
"短语被写进去的那对括号不在它的译文里重复"
);
}
#[test]
fn a_bracketed_echo_answers_the_rest_its_line_leaves() {
let mut lines = vec![
line(
1_000,
Some(3_172),
"Are you man enough to hold it down",
vec![syllable(1_000, 3_172, "Are you man enough to hold it down")],
),
line(
4_639,
Some(6_751),
"(I wanna see you hold it down for me)",
vec![syllable(
4_639,
6_751,
"(I wanna see you hold it down for me)",
)],
),
];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].text_from_any(),
"Are you man enough to hold it down"
);
assert_eq!(
background_text(&lines[0]).as_deref(),
Some("I wanna see you hold it down for me")
);
}
#[test]
fn a_bracketed_phrase_written_across_rows_joins_the_line_it_answers() {
let mut lines = vec![
line(
1_000,
Some(2_586),
"come and drive me crazy",
vec![syllable(1_000, 2_586, "come and drive me crazy")],
),
with_chinese(
line(
2_586,
Some(3_000),
"(If you walk it",
vec![
syllable(2_586, 2_700, "(If "),
syllable(2_700, 2_800, "you "),
syllable(2_800, 2_900, "walk "),
syllable(2_900, 3_000, "it"),
],
),
"如果你言行一致",
),
with_chinese(
line(
3_000,
Some(3_300),
"Baby, stand up",
vec![
syllable(3_000, 3_100, "Baby, "),
syllable(3_100, 3_200, "stand "),
syllable(3_200, 3_300, "up"),
],
),
"宝贝 请挺身而出",
),
line(
3_300,
Some(3_391),
"Baby, we go up)",
vec![
syllable(3_300, 3_330, "Baby, "),
syllable(3_330, 3_360, "we "),
syllable(3_360, 3_390, "go "),
syllable(3_390, 3_391, "up)"),
],
),
line(
4_000,
Some(5_000),
"Put your money",
vec![syllable(4_000, 5_000, "Put your money")],
),
];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 2);
assert_eq!(lines[1].text_from_any(), "Put your money");
assert_eq!(lines[0].text_from_any(), "come and drive me crazy");
let background = lines[0].sub_line().expect("这段短语被并了进来");
assert_eq!(
background.text_from_any(),
"If you walk it Baby, stand up Baby, we go up"
);
assert_eq!(
LineInfo::text_from_syllables(background.syllables().unwrap()),
background.text_from_any(),
"词拼起来正是这段短语写下的样子"
);
assert_eq!(
(background.start_time(), background.end_time()),
(Some(2_586), Some(3_391)),
"短语从它第一行开始的地方唱到它最后一行结束的地方"
);
assert_eq!(
background.chinese_translation(),
Some("如果你言行一致 宝贝 请挺身而出"),
"分几行写的短语每一行都有译文"
);
}
#[test]
fn an_unmatched_bracket_does_not_reach_across_the_song() {
let mut lines = vec![
line(
1_000,
Some(2_000),
"Know the way",
vec![syllable(1_000, 2_000, "Know the way")],
),
line(
2_000,
Some(3_000),
"(hold on",
vec![syllable(2_000, 3_000, "(hold on")],
),
line(
20_000,
Some(21_000),
"sing it",
vec![syllable(20_000, 21_000, "sing it")],
),
line(
21_000,
Some(22_000),
"again)",
vec![syllable(21_000, 22_000, "again)")],
),
];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 4);
assert!(lines[0].sub_line().is_none());
}
#[test]
fn an_unbracketed_translation_keeps_its_text() {
assert_eq!(unwrap_brackets("来吧 尽管…"), "来吧 尽管…");
assert_eq!(unwrap_brackets(" (来吧 尽管…) "), "来吧 尽管…");
assert_eq!(unwrap_brackets("【来吧 尽管…】"), "来吧 尽管…");
assert_eq!(
unwrap_brackets("(来吧"),
"(来吧",
"落单的括号是文本的一部分"
);
assert_eq!(unwrap_brackets("("), "(");
assert_eq!(
unwrap_brackets("(来吧)(尽管)"),
"(来吧)(尽管)",
"两段括号不是一段"
);
assert_eq!(unwrap_brackets("((来吧) 尽管)"), "(来吧) 尽管");
}
#[test]
fn a_label_naming_a_performer_whose_name_holds_a_separator() {
let mut lines = vec![
line(0, Some(500), "Tyler, The Creator:", Vec::new()),
line(1_000, Some(2_000), "Yeah", Vec::new()),
line(
2_000,
Some(2_500),
"Kali Uchis & Tyler, The Creator:",
Vec::new(),
),
line(3_000, Some(4_000), "Together", Vec::new()),
];
apply_speaker_labels(&mut lines, &artists(&["Kali Uchis", "Tyler, The Creator"]));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].alignment(), LyricsAlignment::Right);
assert_eq!(lines[1].alignment(), LyricsAlignment::Left);
}
#[test]
fn a_bracketed_line_far_from_the_previous_one_stays_its_own_line() {
let mut lines = vec![
line(
1_000,
Some(2_000),
"Know the way",
vec![syllable(1_000, 2_000, "Know the way")],
),
line(
9_000,
Some(10_000),
"(Instrumental)",
vec![syllable(9_000, 10_000, "(Instrumental)")],
),
];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 2);
assert_eq!(lines[1].text_from_any(), "(Instrumental)");
assert!(lines[0].sub_line().is_none());
}
#[test]
fn a_bracketed_opening_line_stays_one_ordinary_line() {
let mut lines = vec![line(
0,
Some(1_000),
"(Ella ella)",
vec![syllable(0, 1_000, "(Ella ella)")],
)];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text_from_any(), "(Ella ella)");
assert!(lines[0].sub_line().is_none());
}
fn word_timed_line(start_ms: i32, end_ms: i32, text: &str) -> LineInfo {
let words = text.split(' ').collect::<Vec<_>>();
let last = words.len() - 1;
let step = (end_ms - start_ms) / words.len() as i32;
let syllables = words
.iter()
.enumerate()
.map(|(index, word)| {
let word_start = start_ms + step * index as i32;
let word_end = if index == last {
end_ms
} else {
word_start + step
};
let text = if index == last {
(*word).to_string()
} else {
format!("{word} ")
};
syllable(word_start, word_end, &text)
})
.collect();
line(start_ms, Some(end_ms), text, syllables)
}
fn echo(text: &str) -> LineInfo {
LineInfo::new_line(text.to_string(), Some(0), None)
}
fn artists(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
#[test]
fn a_joint_label_takes_its_side_from_the_performer_it_names_first() {
let mut lines = vec![
line(0, Some(500), "Iggy Azalea/Ariana Grande:", Vec::new()),
line(1_000, Some(2_000), "Uh-huh it's Iggy", Vec::new()),
line(2_000, Some(2_500), "Big Sean/Ariana Grande:", Vec::new()),
line(3_000, Some(4_000), "One less problem", Vec::new()),
];
apply_speaker_labels(&mut lines, &artists(&["Ariana Grande", "Iggy Azalea"]));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text_from_any(), "Uh-huh it's Iggy");
assert_eq!(lines[0].alignment(), LyricsAlignment::Right);
assert_eq!(lines[1].text_from_any(), "One less problem");
assert_eq!(lines[1].alignment(), LyricsAlignment::Left);
}
#[test]
fn a_label_naming_nobody_on_the_record_is_not_a_label() {
let mut lines = vec![line(
1_000,
Some(2_000),
"Big Sean/Some Guy: line",
vec![
syllable(1_000, 1_500, "Big Sean/Some Guy: "),
syllable(1_500, 2_000, "line"),
],
)];
apply_speaker_labels(&mut lines, &artists(&["Ariana Grande", "Iggy Azalea"]));
assert_eq!(lines[0].text_from_any(), "Big Sean/Some Guy: line");
assert_eq!(
lines[0].alignment(),
LyricsAlignment::Unspecified,
"一个歌手都没点到的标签不给这一行定分边"
);
}
#[test]
fn an_inline_label_leaves_the_word_timing_behind() {
let mut lines = vec![line(
1_000,
Some(2_000),
"Doja Cat: sing it",
vec![
syllable(1_000, 1_200, "Doja "),
syllable(1_200, 1_500, "Cat: "),
syllable(1_500, 2_000, "sing it"),
],
)];
apply_speaker_labels(&mut lines, &artists(&["Doja Cat", "SZA"]));
assert_eq!(lines[0].text_from_any(), "sing it");
assert_eq!(lines[0].alignment(), LyricsAlignment::Left);
assert_eq!(texts(&lines[0]), vec!["sing it"]);
}
#[test]
fn a_second_performer_switches_the_voice() {
let mut lines = vec![
line(0, Some(1_000), "The Weeknd:", Vec::new()),
line(1_000, Some(2_000), "I can't feel my face", Vec::new()),
line(2_000, Some(3_000), "Take my hand", Vec::new()),
];
apply_speaker_labels(&mut lines, &artists(&["Ariana Grande", "The Weeknd"]));
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].alignment(), LyricsAlignment::Right);
assert_eq!(lines[1].alignment(), LyricsAlignment::Right);
}
#[test]
fn a_sung_colon_without_a_label_keeps_the_line() {
let mut lines = vec![line(0, Some(1_000), "Love: it hurts", Vec::new())];
apply_speaker_labels(&mut lines, &artists(&["Ariana Grande"]));
assert_eq!(lines[0].text_from_any(), "Love: it hurts");
assert_eq!(lines[0].alignment(), LyricsAlignment::Unspecified);
}
#[test]
fn a_joint_label_gives_its_part_back_to_the_main_voice() {
let mut lines = vec![
line(0, Some(500), "The Weeknd:", Vec::new()),
line(
1_000,
Some(2_000),
"I saw you dancing in a crowded room",
Vec::new(),
),
line(2_000, Some(2_500), "Ariana Grande:", Vec::new()),
line(
3_000,
Some(4_000),
"Met you once under a Pisces moon",
Vec::new(),
),
line(4_000, Some(4_500), "Both:", Vec::new()),
line(
5_000,
Some(6_000),
"I don't know why I run away",
Vec::new(),
),
];
apply_speaker_labels(&mut lines, &artists(&["The Weeknd", "Ariana Grande"]));
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].alignment(), LyricsAlignment::Left);
assert_eq!(lines[1].alignment(), LyricsAlignment::Right);
assert_eq!(
(lines[2].text_from_any().as_str(), lines[2].alignment()),
("I don't know why I run away", LyricsAlignment::Left),
"两人一起唱的一段交回第一个声部"
);
}
#[test]
fn a_joint_label_written_in_chinese_gives_its_part_back_to_the_main_voice() {
let mut lines = vec![
line(0, Some(500), "合:", Vec::new()),
line(1_000, Some(2_000), "合唱:我们一起走吧", Vec::new()),
];
apply_speaker_labels(&mut lines, &artists(&["某人"]));
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text_from_any(), "我们一起走吧");
assert_eq!(lines[0].alignment(), LyricsAlignment::Left);
}
#[test]
fn a_sentence_broken_at_a_comma_is_joined_into_one_line() {
let mut lines = vec![
word_timed_line(194_619, 195_413, "Don't be shy,"),
word_timed_line(195_413, 196_999, "come and drive me crazy"),
];
lines[0] = with_chinese(lines[0].clone(), "不要害羞");
lines[1] = with_chinese(lines[1].clone(), "让我陷入疯狂");
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1, "两行是一句话");
assert_eq!(
lines[0].text_from_any(),
"Don't be shy, come and drive me crazy"
);
assert_eq!(
lines[0].chinese_translation(),
Some("不要害羞 让我陷入疯狂"),
"每一行的译文跟着它自己那一行"
);
assert_eq!(
texts(&lines[0]),
vec![
"Don't ", "be ", "shy, ", "come ", "and ", "drive ", "me ", "crazy"
],
"这句话的词是两行各自画出来的词"
);
assert_eq!(lines[0].start_time(), Some(194_619));
assert_eq!(lines[0].end_time(), Some(196_999));
}
#[test]
fn the_english_pronoun_carries_the_rest_of_a_sentence() {
let mut lines = vec![
word_timed_line(108_000, 108_582, "A tragedy, Ms. RIP,"),
word_timed_line(108_582, 109_800, "I came for a reason"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].text_from_any(),
"A tragedy, Ms. RIP, I came for a reason"
);
}
#[test]
fn the_rest_of_a_sentence_is_joined_without_punctuation_at_the_break() {
let mut lines = vec![
word_timed_line(197_804, 198_300, "Put your money"),
word_timed_line(198_300, 199_100, "where your mouth is"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].text_from_any(),
"Put your money where your mouth is"
);
assert_eq!(lines[0].end_time(), Some(199_100));
}
#[test]
fn a_hook_timed_apart_from_the_row_before_it_keeps_its_rows() {
let mut lines = vec![
word_timed_line(28_415, 29_417, "She a Whole Different Animal"),
word_timed_line(29_953, 30_779, "different animal"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2, "两行是副歌,不是一句话");
}
#[test]
fn the_display_time_in_a_line_header_does_not_join_the_rows_after_it() {
let mut first = word_timed_line(28_415, 29_417, "She a Whole Different Animal");
set_end_time(&mut first, Some(40_000));
let mut lines = vec![first, word_timed_line(29_953, 30_779, "different animal")];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2);
}
#[test]
fn the_display_time_in_a_line_header_does_not_widen_the_echo_window() {
let mut parent = word_timed_line(0, 1_000, "Know that I will find");
set_end_time(&mut parent, Some(20_000));
let mut lines = vec![parent, word_timed_line(10_000, 11_000, "(far away)")];
fold_bracketed_echoes(&mut lines);
assert_eq!(lines.len(), 2, "十秒之后的括号句不是这一行的回声");
assert!(lines[0].sub_line().is_none());
}
#[test]
fn a_row_the_punctuation_left_open_is_joined_however_late_it_is_timed() {
let mut lines = vec![
word_timed_line(39_491, 39_991, "Like zip,"),
word_timed_line(40_572, 40_984, "I don't care"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text_from_any(), "Like zip, I don't care");
}
#[test]
fn a_line_timed_payload_joins_its_rows_by_case_alone() {
let mut lines = vec![
line(1_000, None, "Put your money", Vec::new()),
line(5_000, None, "where your mouth is", Vec::new()),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1);
}
#[test]
fn a_sentence_the_punctuation_closed_keeps_its_rows() {
let mut lines = vec![
word_timed_line(0, 1_000, "I'm not your enemy."),
word_timed_line(1_000, 2_000, "i already know"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2, "前一行说它那一句已经说完");
}
#[test]
fn the_english_pronoun_begins_a_sentence_without_the_punctuation_to_carry_on() {
let mut lines = vec![
word_timed_line(0, 1_000, "These days"),
word_timed_line(1_000, 2_000, "I can't picture my face"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2);
}
#[test]
fn a_row_in_a_script_without_letter_case_says_nothing_about_the_sentence() {
let mut lines = vec![
line(1_000, Some(3_000), "こんにちは世界", Vec::new()),
line(3_000, Some(5_000), "bye", Vec::new()),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2);
}
#[test]
fn a_row_that_begins_a_sentence_keeps_its_own_line() {
let mut lines = vec![
word_timed_line(158_851, 159_400, "Boy, Saddle Up,"),
word_timed_line(159_400, 160_800, "Don't waste my time"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2, "大写的词自己起一句");
}
#[test]
fn a_script_without_letter_case_keeps_its_rows() {
let mut lines = vec![
word_timed_line(148_870, 149_500, "这还远远不够,"),
word_timed_line(149_500, 150_600, "我直言不讳"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2, "逗号在那里断开行,而不是把它留着");
}
#[test]
fn a_duet_answer_keeps_its_own_row() {
let mut lines = vec![
word_timed_line(0, 1_000, "hold on,"),
word_timed_line(1_000, 2_000, "i got you"),
];
set_alignment(&mut lines[1], LyricsAlignment::Right);
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2, "另一位唱的是他自己的一行");
}
#[test]
fn a_sentence_broken_twice_is_joined_into_one_line() {
let mut lines = vec![
word_timed_line(0, 1_000, "Take the reins,"),
word_timed_line(1_000, 2_000, "buckle up,"),
word_timed_line(2_000, 3_000, "my baby"),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(
lines[0].text_from_any(),
"Take the reins, buckle up, my baby"
);
assert_eq!(lines[0].end_time(), Some(3_000));
}
#[test]
fn the_rest_of_a_sentence_keeps_the_echo_it_answers_with() {
let mut lines = vec![
word_timed_line(194_619, 195_413, "Don't be shy,"),
word_timed_line(195_413, 196_999, "come and drive me crazy"),
];
lines[1].set_sub_line(Some(Box::new(echo("If you walk it like you talk it"))));
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 1);
assert_eq!(
background_text(&lines[0]).as_deref(),
Some("If you walk it like you talk it"),
"回声回答的是它写在下面的那句话"
);
}
#[test]
fn rows_timed_differently_keep_their_own_lines() {
let mut lines = vec![
word_timed_line(0, 1_000, "hold on,"),
line(1_000, Some(2_000), "i got you", Vec::new()),
];
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2);
}
#[test]
fn two_echoes_are_not_joined_into_one_line() {
let mut lines = vec![
word_timed_line(0, 1_000, "hold on,"),
word_timed_line(1_000, 2_000, "i got you"),
];
lines[0].set_sub_line(Some(Box::new(echo("yeah"))));
lines[1].set_sub_line(Some(Box::new(echo("oh"))));
merge_continued_lines(&mut lines);
assert_eq!(lines.len(), 2, "一行只带一个背景和声");
}
}