Files
rustytorch/crates/models/rtx-tts/src/text/phonemizer.rs
T
osobhandClaude Sonnet 5 4aaa36a57a style: cargo fmt --workspace (whitespace/wrapping only, no semantic change)
Whole-workspace rustfmt pass picked up while iterating on Mamba GPU
backward work. Verified formatting-only via diff sampling; no logic
changed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-10 07:09:36 -07:00

846 lines
26 KiB
Rust

//! Grapheme-to-phoneme conversion for TTS
//!
//! This module provides phonemization capabilities using:
//! - Dictionary-based lookup for common words
//! - Rule-based fallback for unknown words
//! - ARPAbet phoneme representation for English
use crate::error::{Result, TtsError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Phonemizer backend type
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PhonemizerBackend {
/// Dictionary-based lookup
Dictionary,
/// Rule-based phonemization
Rule,
/// Grapheme-to-phoneme model (future)
G2P,
}
/// Configuration for phonemization
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhonemizerConfig {
/// Language code (e.g., "en-us", "en-gb")
pub language: String,
/// Phonemization backend
pub backend: PhonemizerBackend,
/// Include stress markers (0=no stress, 1=primary, 2=secondary)
pub include_stress: bool,
}
impl Default for PhonemizerConfig {
fn default() -> Self {
Self {
language: "en-us".to_string(),
backend: PhonemizerBackend::Dictionary,
include_stress: true,
}
}
}
/// Phoneme representation with optional stress
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Phoneme {
/// Phoneme symbol (ARPAbet for English)
pub symbol: String,
/// Stress level (0=unstressed, 1=primary, 2=secondary)
pub stress: Option<u8>,
}
impl Phoneme {
/// Create a new phoneme
#[must_use]
pub fn new(symbol: String, stress: Option<u8>) -> Self {
Self { symbol, stress }
}
/// Create a phoneme without stress
#[must_use]
pub fn unstressed(symbol: String) -> Self {
Self {
symbol,
stress: None,
}
}
/// Convert phoneme to string representation
#[must_use]
pub fn to_string_with_stress(&self) -> String {
if let Some(stress) = self.stress {
format!("{}{}", self.symbol, stress)
} else {
self.symbol.clone()
}
}
}
/// Grapheme-to-phoneme converter
#[derive(Debug)]
pub struct Phonemizer {
config: PhonemizerConfig,
dictionary: HashMap<String, Vec<Phoneme>>,
}
impl Phonemizer {
/// Create a new phonemizer with the given configuration
#[must_use]
pub fn new(config: PhonemizerConfig) -> Self {
let mut dictionary = HashMap::new();
// Build English pronunciation dictionary (ARPAbet)
// Common words with stress markers
Self::populate_dictionary(&mut dictionary);
Self { config, dictionary }
}
/// Create a phonemizer with default configuration
#[must_use]
pub fn default() -> Self {
Self::new(PhonemizerConfig::default())
}
/// Get the configuration
#[must_use]
pub fn config(&self) -> &PhonemizerConfig {
&self.config
}
/// Populate the pronunciation dictionary with common English words
fn populate_dictionary(dict: &mut HashMap<String, Vec<Phoneme>>) {
// Pronouns
dict.insert(
"i".to_string(),
vec![Phoneme::new("AY".to_string(), Some(1))],
);
dict.insert(
"you".to_string(),
vec![
Phoneme::new("Y".to_string(), None),
Phoneme::new("UW".to_string(), Some(1)),
],
);
dict.insert(
"he".to_string(),
vec![
Phoneme::new("HH".to_string(), None),
Phoneme::new("IY".to_string(), Some(1)),
],
);
dict.insert(
"she".to_string(),
vec![
Phoneme::new("SH".to_string(), None),
Phoneme::new("IY".to_string(), Some(1)),
],
);
dict.insert(
"it".to_string(),
vec![
Phoneme::new("IH".to_string(), Some(1)),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"we".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("IY".to_string(), Some(1)),
],
);
dict.insert(
"they".to_string(),
vec![
Phoneme::new("DH".to_string(), None),
Phoneme::new("EY".to_string(), Some(1)),
],
);
// Common verbs
dict.insert(
"is".to_string(),
vec![
Phoneme::new("IH".to_string(), Some(1)),
Phoneme::new("Z".to_string(), None),
],
);
dict.insert(
"are".to_string(),
vec![
Phoneme::new("AA".to_string(), Some(1)),
Phoneme::new("R".to_string(), None),
],
);
dict.insert(
"was".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("AA".to_string(), Some(1)),
Phoneme::new("Z".to_string(), None),
],
);
dict.insert(
"have".to_string(),
vec![
Phoneme::new("HH".to_string(), None),
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("V".to_string(), None),
],
);
dict.insert(
"had".to_string(),
vec![
Phoneme::new("HH".to_string(), None),
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"do".to_string(),
vec![
Phoneme::new("D".to_string(), None),
Phoneme::new("UW".to_string(), Some(1)),
],
);
dict.insert(
"did".to_string(),
vec![
Phoneme::new("D".to_string(), None),
Phoneme::new("IH".to_string(), Some(1)),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"said".to_string(),
vec![
Phoneme::new("S".to_string(), None),
Phoneme::new("EH".to_string(), Some(1)),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"go".to_string(),
vec![
Phoneme::new("G".to_string(), None),
Phoneme::new("OW".to_string(), Some(1)),
],
);
dict.insert(
"went".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("EH".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"make".to_string(),
vec![
Phoneme::new("M".to_string(), None),
Phoneme::new("EY".to_string(), Some(1)),
Phoneme::new("K".to_string(), None),
],
);
dict.insert(
"get".to_string(),
vec![
Phoneme::new("G".to_string(), None),
Phoneme::new("EH".to_string(), Some(1)),
Phoneme::new("T".to_string(), None),
],
);
// Common nouns
dict.insert(
"time".to_string(),
vec![
Phoneme::new("T".to_string(), None),
Phoneme::new("AY".to_string(), Some(1)),
Phoneme::new("M".to_string(), None),
],
);
dict.insert(
"day".to_string(),
vec![
Phoneme::new("D".to_string(), None),
Phoneme::new("EY".to_string(), Some(1)),
],
);
dict.insert(
"man".to_string(),
vec![
Phoneme::new("M".to_string(), None),
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
],
);
dict.insert(
"woman".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("UH".to_string(), Some(1)),
Phoneme::new("M".to_string(), None),
Phoneme::new("AH".to_string(), Some(0)),
Phoneme::new("N".to_string(), None),
],
);
dict.insert(
"child".to_string(),
vec![
Phoneme::new("CH".to_string(), None),
Phoneme::new("AY".to_string(), Some(1)),
Phoneme::new("L".to_string(), None),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"world".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("ER".to_string(), Some(1)),
Phoneme::new("L".to_string(), None),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"house".to_string(),
vec![
Phoneme::new("HH".to_string(), None),
Phoneme::new("AW".to_string(), Some(1)),
Phoneme::new("S".to_string(), None),
],
);
dict.insert(
"book".to_string(),
vec![
Phoneme::new("B".to_string(), None),
Phoneme::new("UH".to_string(), Some(1)),
Phoneme::new("K".to_string(), None),
],
);
// Common adjectives
dict.insert(
"good".to_string(),
vec![
Phoneme::new("G".to_string(), None),
Phoneme::new("UH".to_string(), Some(1)),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"great".to_string(),
vec![
Phoneme::new("G".to_string(), None),
Phoneme::new("R".to_string(), None),
Phoneme::new("EY".to_string(), Some(1)),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"new".to_string(),
vec![
Phoneme::new("N".to_string(), None),
Phoneme::new("UW".to_string(), Some(1)),
],
);
dict.insert(
"old".to_string(),
vec![
Phoneme::new("OW".to_string(), Some(1)),
Phoneme::new("L".to_string(), None),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"big".to_string(),
vec![
Phoneme::new("B".to_string(), None),
Phoneme::new("IH".to_string(), Some(1)),
Phoneme::new("G".to_string(), None),
],
);
dict.insert(
"small".to_string(),
vec![
Phoneme::new("S".to_string(), None),
Phoneme::new("M".to_string(), None),
Phoneme::new("AO".to_string(), Some(1)),
Phoneme::new("L".to_string(), None),
],
);
// Articles and common words
dict.insert(
"the".to_string(),
vec![
Phoneme::new("DH".to_string(), None),
Phoneme::new("AH".to_string(), Some(0)),
],
);
dict.insert(
"a".to_string(),
vec![Phoneme::new("AH".to_string(), Some(0))],
);
dict.insert(
"an".to_string(),
vec![
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
],
);
dict.insert(
"and".to_string(),
vec![
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
Phoneme::new("D".to_string(), None),
],
);
dict.insert(
"or".to_string(),
vec![
Phoneme::new("AO".to_string(), Some(1)),
Phoneme::new("R".to_string(), None),
],
);
dict.insert(
"but".to_string(),
vec![
Phoneme::new("B".to_string(), None),
Phoneme::new("AH".to_string(), Some(1)),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"to".to_string(),
vec![
Phoneme::new("T".to_string(), None),
Phoneme::new("UW".to_string(), Some(1)),
],
);
dict.insert(
"in".to_string(),
vec![
Phoneme::new("IH".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
],
);
dict.insert(
"on".to_string(),
vec![
Phoneme::new("AA".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
],
);
dict.insert(
"at".to_string(),
vec![
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"for".to_string(),
vec![
Phoneme::new("F".to_string(), None),
Phoneme::new("AO".to_string(), Some(1)),
Phoneme::new("R".to_string(), None),
],
);
dict.insert(
"with".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("IH".to_string(), Some(1)),
Phoneme::new("DH".to_string(), None),
],
);
// Numbers
dict.insert(
"one".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("AH".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
],
);
dict.insert(
"two".to_string(),
vec![
Phoneme::new("T".to_string(), None),
Phoneme::new("UW".to_string(), Some(1)),
],
);
dict.insert(
"three".to_string(),
vec![
Phoneme::new("TH".to_string(), None),
Phoneme::new("R".to_string(), None),
Phoneme::new("IY".to_string(), Some(1)),
],
);
dict.insert(
"four".to_string(),
vec![
Phoneme::new("F".to_string(), None),
Phoneme::new("AO".to_string(), Some(1)),
Phoneme::new("R".to_string(), None),
],
);
dict.insert(
"five".to_string(),
vec![
Phoneme::new("F".to_string(), None),
Phoneme::new("AY".to_string(), Some(1)),
Phoneme::new("V".to_string(), None),
],
);
// Common contractions
dict.insert(
"can't".to_string(),
vec![
Phoneme::new("K".to_string(), None),
Phoneme::new("AE".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"don't".to_string(),
vec![
Phoneme::new("D".to_string(), None),
Phoneme::new("OW".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
Phoneme::new("T".to_string(), None),
],
);
dict.insert(
"won't".to_string(),
vec![
Phoneme::new("W".to_string(), None),
Phoneme::new("OW".to_string(), Some(1)),
Phoneme::new("N".to_string(), None),
Phoneme::new("T".to_string(), None),
],
);
// Greetings
dict.insert(
"hello".to_string(),
vec![
Phoneme::new("HH".to_string(), None),
Phoneme::new("AH".to_string(), Some(0)),
Phoneme::new("L".to_string(), None),
Phoneme::new("OW".to_string(), Some(1)),
],
);
dict.insert(
"hi".to_string(),
vec![
Phoneme::new("HH".to_string(), None),
Phoneme::new("AY".to_string(), Some(1)),
],
);
dict.insert(
"goodbye".to_string(),
vec![
Phoneme::new("G".to_string(), None),
Phoneme::new("UH".to_string(), Some(1)),
Phoneme::new("D".to_string(), None),
Phoneme::new("B".to_string(), None),
Phoneme::new("AY".to_string(), Some(1)),
],
);
}
/// Convert text to phonemes
///
/// # Errors
///
/// Returns error if phonemization fails
pub fn g2p(&self, text: &str) -> Result<Vec<Vec<Phoneme>>> {
let words: Vec<&str> = text.split_whitespace().collect();
let mut result = Vec::new();
for word in words {
let word_lower = word.to_lowercase();
let phonemes = self.phonemize_word(&word_lower)?;
result.push(phonemes);
}
Ok(result)
}
/// Phonemize a single word
fn phonemize_word(&self, word: &str) -> Result<Vec<Phoneme>> {
// Try dictionary lookup first
if let Some(phonemes) = self.dictionary.get(word) {
return Ok(phonemes.clone());
}
// Fallback to rule-based phonemization
self.rule_based_phonemization(word)
}
/// Rule-based phonemization for unknown words
fn rule_based_phonemization(&self, word: &str) -> Result<Vec<Phoneme>> {
let mut phonemes = Vec::new();
let chars: Vec<char> = word.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
// Simple rule-based conversion (very basic)
let phoneme = match ch {
'a' => Phoneme::new("AE".to_string(), Some(1)),
'b' => Phoneme::unstressed("B".to_string()),
'c' => Phoneme::unstressed("K".to_string()),
'd' => Phoneme::unstressed("D".to_string()),
'e' => Phoneme::new("EH".to_string(), Some(1)),
'f' => Phoneme::unstressed("F".to_string()),
'g' => Phoneme::unstressed("G".to_string()),
'h' => Phoneme::unstressed("HH".to_string()),
'i' => Phoneme::new("IH".to_string(), Some(1)),
'j' => Phoneme::unstressed("JH".to_string()),
'k' => Phoneme::unstressed("K".to_string()),
'l' => Phoneme::unstressed("L".to_string()),
'm' => Phoneme::unstressed("M".to_string()),
'n' => Phoneme::unstressed("N".to_string()),
'o' => Phoneme::new("AA".to_string(), Some(1)),
'p' => Phoneme::unstressed("P".to_string()),
'q' => Phoneme::unstressed("K".to_string()),
'r' => Phoneme::unstressed("R".to_string()),
's' => Phoneme::unstressed("S".to_string()),
't' => Phoneme::unstressed("T".to_string()),
'u' => Phoneme::new("AH".to_string(), Some(1)),
'v' => Phoneme::unstressed("V".to_string()),
'w' => Phoneme::unstressed("W".to_string()),
'x' => Phoneme::unstressed("K".to_string()),
'y' => Phoneme::unstressed("Y".to_string()),
'z' => Phoneme::unstressed("Z".to_string()),
_ => {
i += 1;
continue;
}
};
phonemes.push(phoneme);
i += 1;
}
if phonemes.is_empty() {
return Err(TtsError::Phonemization(format!(
"Could not phonemize word: {word}"
)));
}
Ok(phonemes)
}
/// Convert phonemes to string representation
#[must_use]
pub fn phonemes_to_string(&self, phonemes: &[Vec<Phoneme>]) -> String {
phonemes
.iter()
.map(|word_phonemes| {
word_phonemes
.iter()
.map(|p| {
if self.config.include_stress {
p.to_string_with_stress()
} else {
p.symbol.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
.join(" | ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = PhonemizerConfig::default();
assert_eq!(config.language, "en-us");
assert_eq!(config.backend, PhonemizerBackend::Dictionary);
assert!(config.include_stress);
}
#[test]
fn test_phoneme_creation() {
let phoneme = Phoneme::new("AE".to_string(), Some(1));
assert_eq!(phoneme.symbol, "AE");
assert_eq!(phoneme.stress, Some(1));
assert_eq!(phoneme.to_string_with_stress(), "AE1");
let unstressed = Phoneme::unstressed("B".to_string());
assert_eq!(unstressed.symbol, "B");
assert_eq!(unstressed.stress, None);
assert_eq!(unstressed.to_string_with_stress(), "B");
}
#[test]
fn test_phonemizer_creation() {
let phonemizer = Phonemizer::default();
assert!(!phonemizer.dictionary.is_empty());
}
#[test]
fn test_dictionary_lookup() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("hello").unwrap();
assert_eq!(result.len(), 1);
assert!(!result[0].is_empty());
}
#[test]
fn test_common_words() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("the").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0][0].symbol, "DH");
let result = phonemizer.g2p("hello").unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0][0].symbol, "HH");
}
#[test]
fn test_multiple_words() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("hello world").unwrap();
assert_eq!(result.len(), 2);
assert!(!result[0].is_empty());
assert!(!result[1].is_empty());
}
#[test]
fn test_pronouns() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("i").unwrap();
assert_eq!(result[0][0].symbol, "AY");
assert_eq!(result[0][0].stress, Some(1));
let result = phonemizer.g2p("you").unwrap();
assert_eq!(result[0][0].symbol, "Y");
assert_eq!(result[0][1].symbol, "UW");
}
#[test]
fn test_verbs() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("is").unwrap();
assert_eq!(result[0][0].symbol, "IH");
assert_eq!(result[0][1].symbol, "Z");
let result = phonemizer.g2p("have").unwrap();
assert_eq!(result[0].len(), 3);
}
#[test]
fn test_numbers() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("one two three").unwrap();
assert_eq!(result.len(), 3);
assert!(!result[0].is_empty());
assert!(!result[1].is_empty());
assert!(!result[2].is_empty());
}
#[test]
fn test_contractions() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("can't").unwrap();
assert!(!result[0].is_empty());
let result = phonemizer.g2p("don't").unwrap();
assert!(!result[0].is_empty());
}
#[test]
fn test_rule_based_fallback() {
let phonemizer = Phonemizer::default();
// Unknown word should use rule-based phonemization
let result = phonemizer.g2p("xyz").unwrap();
assert_eq!(result.len(), 1);
assert!(!result[0].is_empty());
}
#[test]
fn test_phonemes_to_string_with_stress() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("hello").unwrap();
let phoneme_str = phonemizer.phonemes_to_string(&result);
assert!(phoneme_str.contains("HH"));
}
#[test]
fn test_phonemes_to_string_without_stress() {
let config = PhonemizerConfig {
language: "en-us".to_string(),
backend: PhonemizerBackend::Dictionary,
include_stress: false,
};
let phonemizer = Phonemizer::new(config);
let result = phonemizer.g2p("hello").unwrap();
let phoneme_str = phonemizer.phonemes_to_string(&result);
assert!(phoneme_str.contains("HH"));
assert!(!phoneme_str.contains('0'));
assert!(!phoneme_str.contains('1'));
}
#[test]
fn test_case_insensitive() {
let phonemizer = Phonemizer::default();
let result1 = phonemizer.g2p("HELLO").unwrap();
let result2 = phonemizer.g2p("hello").unwrap();
let result3 = phonemizer.g2p("Hello").unwrap();
assert_eq!(result1, result2);
assert_eq!(result2, result3);
}
#[test]
fn test_stress_markers() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("hello").unwrap();
// Check that stress markers are present
let has_stress = result[0].iter().any(|p| p.stress.is_some());
assert!(has_stress);
}
#[test]
fn test_sentence_phonemization() {
let phonemizer = Phonemizer::default();
let result = phonemizer.g2p("the quick brown fox").unwrap();
assert_eq!(result.len(), 4);
// Verify each word has phonemes
for word_phonemes in &result {
assert!(!word_phonemes.is_empty());
}
}
}