//! Prosody prediction for natural speech synthesis //! //! This module provides prosody analysis and prediction including: //! - Pause detection at sentence and phrase boundaries //! - Emphasis and stress prediction //! - Intonation pattern prediction (rising/falling) //! - Speaking rate estimation use crate::error::Result; use serde::{Deserialize, Serialize}; /// Configuration for prosody prediction #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProsodyConfig { /// Use punctuation for pause prediction pub use_punctuation_pauses: bool, /// Default speaking rate (1.0 = normal, <1.0 = slower, >1.0 = faster) pub default_speaking_rate: f32, /// Insert pauses at commas pub pause_at_commas: bool, /// Insert pauses at sentence boundaries pub pause_at_sentences: bool, /// Detect emphasis from capitalization pub detect_emphasis: bool, } impl Default for ProsodyConfig { fn default() -> Self { Self { use_punctuation_pauses: true, default_speaking_rate: 1.0, pause_at_commas: true, pause_at_sentences: true, detect_emphasis: true, } } } /// Prosody marker types #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ProsodyMarker { /// Pause with duration in milliseconds Pause(u32), /// Emphasis on word or syllable Emphasis, /// Rising intonation (questions) Rising, /// Falling intonation (statements) Falling, /// Continuation (phrase boundary) Continuation, } /// Prosody information for a text segment #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProsodyAnnotation { /// Text segment pub text: String, /// Start position in original text pub start_pos: usize, /// End position in original text pub end_pos: usize, /// Prosody markers for this segment pub markers: Vec, /// Duration factor (relative to normal speaking rate) pub duration_factor: f32, } impl ProsodyAnnotation { /// Create a new prosody annotation #[must_use] pub fn new(text: String, start_pos: usize, end_pos: usize) -> Self { Self { text, start_pos, end_pos, markers: Vec::new(), duration_factor: 1.0, } } /// Add a marker to this annotation pub fn add_marker(&mut self, marker: ProsodyMarker) { self.markers.push(marker); } /// Check if annotation has a specific marker type #[must_use] pub fn has_marker(&self, marker_type: &ProsodyMarker) -> bool { self.markers .iter() .any(|m| std::mem::discriminant(m) == std::mem::discriminant(marker_type)) } } /// Prosody predictor #[derive(Debug)] pub struct Prosody { config: ProsodyConfig, } impl Prosody { /// Create a new prosody predictor with the given configuration #[must_use] pub fn new(config: ProsodyConfig) -> Self { Self { config } } /// Create a prosody predictor with default configuration #[must_use] pub fn default() -> Self { Self::new(ProsodyConfig::default()) } /// Predict prosody for the given text /// /// # Errors /// /// Returns error if prosody prediction fails pub fn predict(&self, text: &str) -> Result> { let mut annotations = Vec::new(); // Split into sentences let sentences = self.detect_sentence_boundaries(text)?; for (sentence, start_pos) in sentences { let sentence_annotations = self.process_sentence(&sentence, start_pos)?; annotations.extend(sentence_annotations); } Ok(annotations) } /// Detect sentence boundaries fn detect_sentence_boundaries(&self, text: &str) -> Result> { let mut sentences = Vec::new(); let mut current_sentence = String::new(); let mut start_pos = 0; for (i, ch) in text.char_indices() { current_sentence.push(ch); // Check for sentence-ending punctuation if ch == '.' || ch == '!' || ch == '?' { // Look ahead to see if this is really the end of a sentence let next_char = text.chars().nth(i + 1); if next_char.is_none() || next_char == Some(' ') || next_char == Some('\n') { sentences.push((current_sentence.trim().to_string(), start_pos)); current_sentence.clear(); start_pos = i + 1; } } } // Add remaining text as a sentence if !current_sentence.trim().is_empty() { sentences.push((current_sentence.trim().to_string(), start_pos)); } Ok(sentences) } /// Process a single sentence fn process_sentence(&self, sentence: &str, base_pos: usize) -> Result> { let mut annotations = Vec::new(); // Detect phrase boundaries (commas, semicolons, etc.) let phrases = self.detect_phrase_boundaries(sentence)?; for (i, (phrase, start_offset)) in phrases.iter().enumerate() { let start_pos = base_pos + start_offset; let end_pos = start_pos + phrase.len(); let mut annotation = ProsodyAnnotation::new(phrase.clone(), start_pos, end_pos); // Add pause markers if self.config.use_punctuation_pauses { // Pause at phrase boundaries if i < phrases.len() - 1 && self.config.pause_at_commas { annotation.add_marker(ProsodyMarker::Pause(200)); } } // Detect emphasis from capitalization if self.config.detect_emphasis { if phrase.chars().filter(|c| c.is_uppercase()).count() > phrase.len() / 2 { annotation.add_marker(ProsodyMarker::Emphasis); } } // Detect intonation from punctuation if sentence.ends_with('?') && i == phrases.len() - 1 { annotation.add_marker(ProsodyMarker::Rising); } else if sentence.ends_with('.') || sentence.ends_with('!') { if i == phrases.len() - 1 { annotation.add_marker(ProsodyMarker::Falling); } else { annotation.add_marker(ProsodyMarker::Continuation); } } // Estimate duration factor annotation.duration_factor = self.estimate_duration_factor(phrase); annotations.push(annotation); } // Add sentence-final pause if self.config.pause_at_sentences && !annotations.is_empty() { if let Some(last) = annotations.last_mut() { last.add_marker(ProsodyMarker::Pause(400)); } } Ok(annotations) } /// Detect phrase boundaries within a sentence fn detect_phrase_boundaries(&self, sentence: &str) -> Result> { let mut phrases = Vec::new(); let mut current_phrase = String::new(); let mut start_pos = 0; for (i, ch) in sentence.char_indices() { current_phrase.push(ch); // Check for phrase-ending punctuation if ch == ',' || ch == ';' || ch == ':' { phrases.push((current_phrase.trim().to_string(), start_pos)); current_phrase.clear(); start_pos = i + 1; } } // Add remaining text as a phrase if !current_phrase.trim().is_empty() { phrases.push((current_phrase.trim().to_string(), start_pos)); } Ok(phrases) } /// Estimate duration factor for a phrase fn estimate_duration_factor(&self, phrase: &str) -> f32 { // Base duration on speaking rate let mut factor = self.config.default_speaking_rate; // Longer phrases might be spoken slightly slower let word_count = phrase.split_whitespace().count(); if word_count > 10 { factor *= 0.95; } // Short exclamations might be faster if word_count <= 2 && phrase.contains('!') { factor *= 1.1; } factor } /// Get the configuration #[must_use] pub fn config(&self) -> &ProsodyConfig { &self.config } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_config() { let config = ProsodyConfig::default(); assert!(config.use_punctuation_pauses); assert_eq!(config.default_speaking_rate, 1.0); assert!(config.pause_at_commas); assert!(config.pause_at_sentences); assert!(config.detect_emphasis); } #[test] fn test_prosody_marker_equality() { let pause1 = ProsodyMarker::Pause(200); let pause2 = ProsodyMarker::Pause(300); let emphasis = ProsodyMarker::Emphasis; assert_eq!(pause1, ProsodyMarker::Pause(200)); assert_ne!(pause1, pause2); assert_ne!(pause1, emphasis); } #[test] fn test_prosody_annotation_creation() { let mut annotation = ProsodyAnnotation::new("hello".to_string(), 0, 5); assert_eq!(annotation.text, "hello"); assert_eq!(annotation.start_pos, 0); assert_eq!(annotation.end_pos, 5); assert!(annotation.markers.is_empty()); assert_eq!(annotation.duration_factor, 1.0); annotation.add_marker(ProsodyMarker::Emphasis); assert_eq!(annotation.markers.len(), 1); assert!(annotation.has_marker(&ProsodyMarker::Emphasis)); } #[test] fn test_prosody_creation() { let prosody = Prosody::default(); assert_eq!(prosody.config().default_speaking_rate, 1.0); } #[test] fn test_sentence_boundary_detection() { let prosody = Prosody::default(); let text = "Hello world. How are you?"; let sentences = prosody.detect_sentence_boundaries(text).unwrap(); assert_eq!(sentences.len(), 2); assert_eq!(sentences[0].0, "Hello world."); assert_eq!(sentences[1].0, "How are you?"); } #[test] fn test_phrase_boundary_detection() { let prosody = Prosody::default(); let sentence = "Hello, how are you today?"; let phrases = prosody.detect_phrase_boundaries(sentence).unwrap(); assert_eq!(phrases.len(), 2); assert_eq!(phrases[0].0, "Hello,"); assert_eq!(phrases[1].0, "how are you today?"); } #[test] fn test_simple_sentence_prosody() { let prosody = Prosody::default(); let result = prosody.predict("Hello world.").unwrap(); assert!(!result.is_empty()); // Should have a sentence-final pause assert!(result.last().unwrap().has_marker(&ProsodyMarker::Pause(0))); } #[test] fn test_question_intonation() { let prosody = Prosody::default(); let result = prosody.predict("How are you?").unwrap(); assert!(!result.is_empty()); // Should have rising intonation assert!(result.last().unwrap().has_marker(&ProsodyMarker::Rising)); } #[test] fn test_statement_intonation() { let prosody = Prosody::default(); let result = prosody.predict("I am fine.").unwrap(); assert!(!result.is_empty()); // Should have falling intonation assert!(result.last().unwrap().has_marker(&ProsodyMarker::Falling)); } #[test] fn test_comma_pause() { let config = ProsodyConfig { use_punctuation_pauses: true, pause_at_commas: true, ..Default::default() }; let prosody = Prosody::new(config); let result = prosody.predict("Hello, world.").unwrap(); assert_eq!(result.len(), 2); // First phrase should have a pause after comma assert!(result[0].has_marker(&ProsodyMarker::Pause(0))); } #[test] fn test_multiple_sentences() { let prosody = Prosody::default(); let result = prosody .predict("Hello world. How are you? I am fine.") .unwrap(); assert!(!result.is_empty()); // Multiple sentences should be detected assert!(result.len() >= 3); } #[test] fn test_emphasis_detection() { let config = ProsodyConfig { detect_emphasis: true, ..Default::default() }; let prosody = Prosody::new(config); let result = prosody.predict("HELLO WORLD").unwrap(); assert!(!result.is_empty()); // Should detect emphasis from capitalization assert!(result[0].has_marker(&ProsodyMarker::Emphasis)); } #[test] fn test_duration_factor_estimation() { let prosody = Prosody::default(); let short_factor = prosody.estimate_duration_factor("Hi!"); let normal_factor = prosody.estimate_duration_factor("This is a normal sentence"); let long_factor = prosody.estimate_duration_factor( "This is a very long sentence with many words that should be spoken slowly", ); assert!(short_factor >= normal_factor); assert!(normal_factor >= long_factor); } #[test] fn test_no_punctuation_pauses() { let config = ProsodyConfig { use_punctuation_pauses: false, pause_at_commas: false, pause_at_sentences: false, ..Default::default() }; let prosody = Prosody::new(config); let result = prosody.predict("Hello, world.").unwrap(); // Should not have pauses for annotation in &result { assert!(!annotation.has_marker(&ProsodyMarker::Pause(0))); } } #[test] fn test_speaking_rate() { let config = ProsodyConfig { default_speaking_rate: 1.5, ..Default::default() }; let prosody = Prosody::new(config); let result = prosody.predict("Hello world.").unwrap(); assert!(!result.is_empty()); // Duration factors should reflect faster speaking rate assert!(result[0].duration_factor >= 1.0); } #[test] fn test_semicolon_boundary() { let prosody = Prosody::default(); let result = prosody.predict("First part; second part.").unwrap(); assert_eq!(result.len(), 2); assert_eq!(result[0].text, "First part;"); assert_eq!(result[1].text, "second part."); } #[test] fn test_exclamation_intonation() { let prosody = Prosody::default(); let result = prosody.predict("Hello!").unwrap(); assert!(!result.is_empty()); // Exclamations should have falling intonation assert!(result.last().unwrap().has_marker(&ProsodyMarker::Falling)); } #[test] fn test_empty_text() { let prosody = Prosody::default(); let result = prosody.predict("").unwrap(); assert!(result.is_empty()); } #[test] fn test_whitespace_only() { let prosody = Prosody::default(); let result = prosody.predict(" ").unwrap(); assert!(result.is_empty()); } #[test] fn test_complex_punctuation() { let prosody = Prosody::default(); let result = prosody.predict("Wait... what? Really!").unwrap(); assert!(!result.is_empty()); // Should handle multiple punctuation marks assert!(result.len() >= 2); } }