use crate::g2p::misaki::MToken;
#[derive(Debug, Clone, PartialEq)]
pub struct TextChunk {
pub graphemes: String,
pub phonemes: String,
}
pub(crate) fn split_segments(text: &str) -> Vec<&str> {
text.trim().split('\n').collect()
}
fn tokens_to_ps(tokens: &[MToken]) -> String {
let mut out = String::new();
for t in tokens {
out.push_str(t.phonemes.as_deref().unwrap_or(""));
if !t.whitespace.is_empty() {
out.push(' ');
}
}
out.trim().to_string()
}
fn tokens_to_text(tokens: &[MToken]) -> String {
let mut out = String::new();
for t in tokens {
out.push_str(&t.text);
out.push_str(&t.whitespace);
}
out.trim().to_string()
}
fn phonemes_is_one_of(phonemes: &Option<String>, candidates: &str) -> bool {
let Some(p) = phonemes.as_deref() else {
return false;
};
let mut chars = p.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => candidates.contains(c),
_ => false,
}
}
fn waterfall_last(tokens: &[MToken], next_count: usize) -> usize {
const WATERFALL: [&str; 3] = ["!.?…", ":;", ",—"];
const BUMPS: &str = ")”";
for level in WATERFALL {
let Some(mut z) = tokens
.iter()
.enumerate()
.rev()
.find(|(_, t)| phonemes_is_one_of(&t.phonemes, level))
.map(|(i, _)| i + 1)
else {
continue;
};
if z < tokens.len() && phonemes_is_one_of(&tokens[z].phonemes, BUMPS) {
z += 1;
}
let head_len = tokens_to_ps(&tokens[..z]).chars().count();
if next_count.saturating_sub(head_len) <= 510 {
return z;
}
}
tokens.len()
}
pub(crate) fn en_tokenize(tokens: Vec<MToken>) -> Vec<TextChunk> {
let mut out = Vec::new();
let mut run: Vec<MToken> = Vec::new();
let mut pcount: usize = 0;
for mut t in tokens {
if t.phonemes.is_none() {
t.phonemes = Some(String::new());
}
let mut next_ps = t.phonemes.clone().unwrap_or_default();
if !t.whitespace.is_empty() {
next_ps.push(' ');
}
let next_pcount = pcount + next_ps.trim_end().chars().count();
if next_pcount > 510 {
let z = waterfall_last(&run, next_pcount);
out.push(TextChunk {
graphemes: tokens_to_text(&run[..z]),
phonemes: tokens_to_ps(&run[..z]),
});
run = run.split_off(z);
pcount = tokens_to_ps(&run).chars().count();
if run.is_empty() {
next_ps = next_ps.trim_start().to_string();
}
}
pcount += next_ps.chars().count();
run.push(t);
}
if !run.is_empty() {
out.push(TextChunk {
graphemes: tokens_to_text(&run),
phonemes: tokens_to_ps(&run),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn tok(text: &str, ws: &str, ps: Option<&str>) -> MToken {
let mut t = MToken::new(text, "NN", ws);
t.phonemes = ps.map(str::to_string);
t
}
#[test]
fn split_segments_drops_blank_runs() {
assert_eq!(split_segments("a\n\nb"), vec!["a", "", "b"]);
assert_eq!(split_segments(" a\nb "), vec!["a", "b"]);
}
#[test]
fn single_short_sentence_is_one_chunk() {
let tokens = vec![tok("Hi", "", Some("hˈI")), tok(".", "", Some("."))];
let chunks = en_tokenize(tokens);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].graphemes, "Hi.");
assert_eq!(chunks[0].phonemes, "hˈI.");
}
#[test]
fn unresolved_word_reads_as_empty_phonemes() {
let tokens = vec![tok("word", "", None)];
let chunks = en_tokenize(tokens);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].phonemes, "");
}
#[test]
fn long_run_splits_at_sentence_boundary() {
let tokens = vec![
tok("a", " ", Some(&"x".repeat(300))),
tok(".", " ", Some(".")),
tok("b", "", Some(&"y".repeat(300))),
];
let chunks = en_tokenize(tokens);
assert_eq!(chunks.len(), 2);
assert!(chunks[0].phonemes.ends_with('.'));
assert_eq!(chunks[1].phonemes, "y".repeat(300));
}
#[test]
fn no_waterfall_boundary_falls_back_to_whole_run() {
let tokens = vec![tok("word", "", Some(&"x".repeat(600)))];
let chunks = en_tokenize(tokens);
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].phonemes, "");
assert_eq!(chunks[1].phonemes.chars().count(), 600);
}
}