use splintr::AnyTokenizer;
#[derive(Debug, Clone, PartialEq)]
pub struct WordTiming {
pub text: String,
pub start: f64,
pub end: f64,
}
const CJK_LANGUAGE_CODES: [&str; 6] = ["zh", "ja", "th", "lo", "my", "yue"];
const PREPEND_PUNCTUATIONS: &str = "\"'\u{201c}\u{a1}\u{bf}([{-";
const APPEND_PUNCTUATIONS: &str =
"\"'.\u{3002},\u{ff0c}!\u{ff01}?\u{ff1f}:\u{ff1a}\u{201d})]}\u{3001}";
const ASCII_PUNCTUATION_SET: &str = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
pub fn combine_tokens_into_words(
tokenizer: &AnyTokenizer,
tokens: &[u32],
language: Option<&str>,
eos_token_id: u32,
) -> (Vec<String>, Vec<Vec<usize>>) {
let (mut words, mut word_tokens, mut token_indices) = if is_cjk_language(language) {
split_tokens_on_unicode(tokenizer, tokens)
} else {
split_tokens_on_spaces(tokenizer, tokens, eos_token_id)
};
merge_punctuations(
&mut words,
&mut word_tokens,
&mut token_indices,
PREPEND_PUNCTUATIONS,
APPEND_PUNCTUATIONS,
);
(words, token_indices)
}
fn is_cjk_language(language: Option<&str>) -> bool {
matches!(language, Some(code) if CJK_LANGUAGE_CODES.contains(&code))
}
fn split_tokens_on_unicode(
tokenizer: &AnyTokenizer,
tokens: &[u32],
) -> (Vec<String>, Vec<Vec<u32>>, Vec<Vec<usize>>) {
let decoded_full = tokenizer.decode_lossy(tokens);
let decoded_full_chars: Vec<char> = decoded_full.chars().collect();
const REPLACEMENT: char = '\u{fffd}';
let mut words = Vec::new();
let mut word_tokens = Vec::new();
let mut token_indices = Vec::new();
let mut current_tokens: Vec<u32> = Vec::new();
let mut current_indices: Vec<usize> = Vec::new();
let mut unicode_offset = 0usize;
for (token_idx, &token) in tokens.iter().enumerate() {
current_tokens.push(token);
current_indices.push(token_idx);
let decoded = tokenizer.decode_lossy(¤t_tokens);
let closes_word = match decoded.chars().position(|c| c == REPLACEMENT) {
None => true,
Some(idx_in_decoded) => {
let full_idx = unicode_offset + idx_in_decoded;
decoded_full_chars.get(full_idx) == Some(&REPLACEMENT)
}
};
if closes_word {
unicode_offset += decoded.chars().count();
words.push(decoded);
word_tokens.push(std::mem::take(&mut current_tokens));
token_indices.push(std::mem::take(&mut current_indices));
}
}
(words, word_tokens, token_indices)
}
fn split_tokens_on_spaces(
tokenizer: &AnyTokenizer,
tokens: &[u32],
eos_token_id: u32,
) -> (Vec<String>, Vec<Vec<u32>>, Vec<Vec<usize>>) {
let (subwords, subword_tokens, subword_indices) = split_tokens_on_unicode(tokenizer, tokens);
let mut words: Vec<String> = Vec::new();
let mut word_tokens: Vec<Vec<u32>> = Vec::new();
let mut token_indices: Vec<Vec<usize>> = Vec::new();
for ((subword, sub_tokens), sub_indices) in subwords
.into_iter()
.zip(subword_tokens)
.zip(subword_indices)
{
let special = sub_tokens.first().is_some_and(|&t| t >= eos_token_id);
let with_space = subword.starts_with(' ');
let punctuation = ASCII_PUNCTUATION_SET.contains(subword.trim());
if special || with_space || punctuation || words.is_empty() {
words.push(subword);
word_tokens.push(sub_tokens);
token_indices.push(sub_indices);
} else {
let last = words.len() - 1;
words[last].push_str(&subword);
word_tokens[last].extend(sub_tokens);
token_indices[last].extend(sub_indices);
}
}
(words, word_tokens, token_indices)
}
fn merge_punctuations(
words: &mut Vec<String>,
word_tokens: &mut Vec<Vec<u32>>,
token_indices: &mut Vec<Vec<usize>>,
prepend: &str,
append: &str,
) {
if !words.is_empty() {
let mut i = words.len() as isize - 2;
let mut j = words.len() - 1;
while i >= 0 {
let ii = i as usize;
if words[ii].starts_with(' ') && prepend.contains(words[ii].trim()) {
let moved = std::mem::take(&mut words[ii]);
words[j] = format!("{moved}{}", words[j]);
let moved_tokens = std::mem::take(&mut word_tokens[ii]);
word_tokens[j] = [moved_tokens, std::mem::take(&mut word_tokens[j])].concat();
let moved_indices = std::mem::take(&mut token_indices[ii]);
token_indices[j] = [moved_indices, std::mem::take(&mut token_indices[j])].concat();
} else {
j = ii;
}
i -= 1;
}
}
if words.len() > 1 {
let mut i = 0usize;
let mut j = 1usize;
while j < words.len() {
if !words[i].ends_with(' ') && append.contains(words[j].as_str()) {
let moved = std::mem::take(&mut words[j]);
words[i].push_str(&moved);
let moved_tokens = std::mem::take(&mut word_tokens[j]);
word_tokens[i].extend(moved_tokens);
let moved_indices = std::mem::take(&mut token_indices[j]);
token_indices[i].extend(moved_indices);
} else {
i = j;
}
j += 1;
}
}
let mut kept_words = Vec::with_capacity(words.len());
let mut kept_tokens = Vec::with_capacity(word_tokens.len());
let mut kept_indices = Vec::with_capacity(token_indices.len());
for ((word, tokens), indices) in words
.drain(..)
.zip(word_tokens.drain(..))
.zip(token_indices.drain(..))
{
if !word.is_empty() {
kept_words.push(word);
kept_tokens.push(tokens);
kept_indices.push(indices);
}
}
*words = kept_words;
*word_tokens = kept_tokens;
*token_indices = kept_indices;
}
pub fn collate_word_timestamps(
words: &[String],
token_indices: &[Vec<usize>],
full_token_timestamps: &[f32],
prefix_len: usize,
) -> Vec<WordTiming> {
words
.iter()
.zip(token_indices.iter())
.filter_map(|(word, indices)| {
let first = *indices.first()?;
let last = *indices.last()?;
let start = full_token_timestamps.get(prefix_len - 1 + first).copied()?;
let end = full_token_timestamps.get(prefix_len + last).copied()?;
Some(WordTiming {
text: word.clone(),
start: hundredths(start),
end: hundredths(end),
})
})
.collect()
}
fn hundredths(t: f32) -> f64 {
(f64::from(t) * 100.0).round() / 100.0
}
#[cfg(test)]
mod tests {
use super::*;
use splintr::{PretrainedVocab, from_vocab};
fn tokenizer() -> AnyTokenizer {
from_vocab(PretrainedVocab::WhisperV2).expect("bundled whisper vocab")
}
#[test]
fn merges_standalone_leading_punctuation() {
let mut words = vec![" (".to_string(), "hello".to_string()];
let mut word_tokens = vec![vec![1u32], vec![2u32]];
let mut token_indices = vec![vec![0usize], vec![1usize]];
merge_punctuations(
&mut words,
&mut word_tokens,
&mut token_indices,
PREPEND_PUNCTUATIONS,
APPEND_PUNCTUATIONS,
);
assert_eq!(words, vec![" (hello".to_string()]);
assert_eq!(word_tokens, vec![vec![1u32, 2]]);
assert_eq!(token_indices, vec![vec![0usize, 1]]);
}
#[test]
fn merges_standalone_trailing_punctuation() {
let mut words = vec![" hello".to_string(), ".".to_string()];
let mut word_tokens = vec![vec![1u32], vec![2u32]];
let mut token_indices = vec![vec![0usize], vec![1usize]];
merge_punctuations(
&mut words,
&mut word_tokens,
&mut token_indices,
PREPEND_PUNCTUATIONS,
APPEND_PUNCTUATIONS,
);
assert_eq!(words, vec![" hello.".to_string()]);
assert_eq!(word_tokens, vec![vec![1u32, 2]]);
}
#[test]
fn cjk_language_detection_uses_codes_not_names() {
assert!(is_cjk_language(Some("zh")));
assert!(is_cjk_language(Some("yue")));
assert!(!is_cjk_language(Some("en")));
assert!(!is_cjk_language(None));
}
#[test]
fn collate_word_timestamps_skips_interior_boundaries() {
let full_ts = [0.0f32, 0.0, 0.0, 0.0, 0.5, 0.8, 1.2, 1.2];
let words = vec!["hello".to_string()];
let token_indices = vec![vec![0usize, 1, 2]];
let timings = collate_word_timestamps(&words, &token_indices, &full_ts, 4);
assert_eq!(timings.len(), 1);
assert_eq!(timings[0].start, 0.0); assert_eq!(timings[0].end, 1.2); }
#[test]
fn collated_times_are_exact_hundredths() {
let full_ts = [0.0f32, 532.0 * 0.02, 536.0 * 0.02, 536.0 * 0.02];
let words = vec!["the".to_string()];
let timings = collate_word_timestamps(&words, &[vec![0usize]], &full_ts, 2);
assert_eq!(timings[0].start, 10.64);
assert_eq!(timings[0].end, 10.72);
assert!(timings[0].end - timings[0].start >= 0.08);
}
#[test]
fn split_tokens_on_unicode_runs_on_real_tokenizer() {
let tok = tokenizer();
let ids = tok.encode(" hello world");
let (words, word_tokens, token_indices) = split_tokens_on_unicode(&tok, &ids);
assert_eq!(words.len(), word_tokens.len());
assert_eq!(words.len(), token_indices.len());
let total_tokens: usize = word_tokens.iter().map(|t| t.len()).sum();
assert_eq!(total_tokens, ids.len());
assert_eq!(words.concat(), tok.decode_lossy(&ids));
}
}