Files
rustytorch/crates/models/rtx-tts/src/text/prosody.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

496 lines
15 KiB
Rust

//! 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<ProsodyMarker>,
/// 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<Vec<ProsodyAnnotation>> {
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<Vec<(String, usize)>> {
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<Vec<ProsodyAnnotation>> {
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<Vec<(String, usize)>> {
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);
}
}