#![forbid(unsafe_code)]
#![allow(clippy::cast_lossless)]
#![allow(clippy::cast_precision_loss)]
use super::metadata::{LanguageTag, SpeakerInfo, VoiceCharacteristics};
use crate::AudioResult;
use std::collections::HashMap;
pub trait TtsEngine {
fn synthesize(&mut self, text: &str, config: &SynthesisConfig) -> AudioResult<Vec<f64>>;
fn estimate_duration(&self, text: &str, config: &SynthesisConfig) -> f64;
fn supports_language(&self, language: &LanguageTag) -> bool;
fn available_voices(&self, language: &LanguageTag) -> Vec<VoiceInfo>;
}
#[derive(Clone, Debug)]
pub struct SynthesisConfig {
pub voice: VoiceInfo,
pub rate: f64,
pub pitch_semitones: f64,
pub volume: f64,
pub sample_rate: u32,
pub enable_ssml: bool,
pub pronunciation_dict: PronunciationDictionary,
}
impl Default for SynthesisConfig {
fn default() -> Self {
Self {
voice: VoiceInfo::default(),
rate: 1.0,
pitch_semitones: 0.0,
volume: 1.0,
sample_rate: 48000,
enable_ssml: false,
pronunciation_dict: PronunciationDictionary::new(),
}
}
}
impl SynthesisConfig {
#[must_use]
pub fn new(voice: VoiceInfo) -> Self {
Self {
voice,
..Default::default()
}
}
#[must_use]
pub fn with_rate(mut self, rate: f64) -> Self {
self.rate = rate.clamp(0.5, 2.0);
self
}
#[must_use]
pub fn with_pitch(mut self, semitones: f64) -> Self {
self.pitch_semitones = semitones.clamp(-12.0, 12.0);
self
}
#[must_use]
pub fn with_volume(mut self, volume: f64) -> Self {
self.volume = volume.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn with_sample_rate(mut self, sample_rate: u32) -> Self {
self.sample_rate = sample_rate;
self
}
#[must_use]
pub fn with_ssml(mut self, enabled: bool) -> Self {
self.enable_ssml = enabled;
self
}
#[must_use]
pub fn with_pronunciation_dict(mut self, dict: PronunciationDictionary) -> Self {
self.pronunciation_dict = dict;
self
}
}
#[derive(Clone, Debug)]
pub struct VoiceInfo {
pub id: String,
pub name: String,
pub language: LanguageTag,
pub characteristics: VoiceCharacteristics,
pub is_neural: bool,
}
impl Default for VoiceInfo {
fn default() -> Self {
Self {
id: String::from("default"),
name: String::from("Default Voice"),
language: LanguageTag::default(),
characteristics: VoiceCharacteristics::default(),
is_neural: false,
}
}
}
impl VoiceInfo {
#[must_use]
pub fn new(id: impl Into<String>, name: impl Into<String>, language: LanguageTag) -> Self {
Self {
id: id.into(),
name: name.into(),
language,
..Default::default()
}
}
#[must_use]
pub fn with_characteristics(mut self, characteristics: VoiceCharacteristics) -> Self {
self.characteristics = characteristics;
self
}
#[must_use]
pub fn with_neural(mut self, neural: bool) -> Self {
self.is_neural = neural;
self
}
#[must_use]
pub fn from_speaker_info(speaker: &SpeakerInfo, language: LanguageTag) -> Self {
Self {
id: format!("speaker_{}", speaker.name),
name: speaker.name.clone(),
language,
characteristics: speaker.voice_characteristics.clone(),
is_neural: true,
}
}
}
#[derive(Clone, Debug, Default)]
pub struct PronunciationDictionary {
entries: HashMap<String, String>,
}
impl PronunciationDictionary {
#[must_use]
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn add(&mut self, word: impl Into<String>, pronunciation: impl Into<String>) {
self.entries
.insert(word.into().to_lowercase(), pronunciation.into());
}
#[must_use]
pub fn get(&self, word: &str) -> Option<&str> {
self.entries.get(&word.to_lowercase()).map(String::as_str)
}
pub fn remove(&mut self, word: &str) -> bool {
self.entries.remove(&word.to_lowercase()).is_some()
}
pub fn clear(&mut self) {
self.entries.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn apply(&self, text: &str) -> String {
let mut result = text.to_string();
for (word, pronunciation) in &self.entries {
let lower_word = word.to_lowercase();
result = result
.replace(&lower_word, pronunciation)
.replace(&word.to_uppercase(), pronunciation)
.replace(&Self::capitalize(&lower_word), pronunciation);
}
result
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
}
}
pub struct SsmlProcessor {
base_config: SynthesisConfig,
}
impl SsmlProcessor {
#[must_use]
pub fn new(base_config: SynthesisConfig) -> Self {
Self { base_config }
}
pub fn parse(&self, ssml: &str) -> AudioResult<Vec<SsmlSegment>> {
let mut segments = Vec::new();
if ssml.trim().is_empty() {
return Ok(segments);
}
let text = self.strip_tags(ssml);
segments.push(SsmlSegment {
text,
rate: self.base_config.rate,
pitch_semitones: self.base_config.pitch_semitones,
volume: self.base_config.volume,
pause_ms: 0.0,
});
Ok(segments)
}
fn strip_tags(&self, ssml: &str) -> String {
let mut result = String::new();
let mut in_tag = false;
for ch in ssml.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
result.trim().to_string()
}
#[must_use]
pub fn generate(&self, text: &str, rate: f64, pitch_semitones: f64, volume: f64) -> String {
let rate_percent = (rate * 100.0) as i32;
let pitch_str = if pitch_semitones >= 0.0 {
format!("+{}st", pitch_semitones as i32)
} else {
format!("{}st", pitch_semitones as i32)
};
format!(
"<speak><prosody rate=\"{}%\" pitch=\"{}\" volume=\"{}\">{}</prosody></speak>",
rate_percent,
pitch_str,
(volume * 100.0) as i32,
text
)
}
}
#[derive(Clone, Debug)]
pub struct SsmlSegment {
pub text: String,
pub rate: f64,
pub pitch_semitones: f64,
pub volume: f64,
pub pause_ms: f64,
}
#[derive(Clone, Debug)]
pub struct ProsodyControl {
base_rate: f64,
base_pitch: f64,
base_volume: f64,
}
impl Default for ProsodyControl {
fn default() -> Self {
Self {
base_rate: 1.0,
base_pitch: 0.0,
base_volume: 1.0,
}
}
}
impl ProsodyControl {
#[must_use]
pub fn new(rate: f64, pitch: f64, volume: f64) -> Self {
Self {
base_rate: rate.clamp(0.5, 2.0),
base_pitch: pitch.clamp(-12.0, 12.0),
base_volume: volume.clamp(0.0, 1.0),
}
}
#[must_use]
pub fn with_emphasis(&self, level: EmphasisLevel) -> Self {
let (rate_mult, pitch_add) = match level {
EmphasisLevel::None => (1.0, 0.0),
EmphasisLevel::Reduced => (1.1, -1.0),
EmphasisLevel::Moderate => (0.95, 2.0),
EmphasisLevel::Strong => (0.9, 4.0),
};
Self {
base_rate: (self.base_rate * rate_mult).clamp(0.5, 2.0),
base_pitch: (self.base_pitch + pitch_add).clamp(-12.0, 12.0),
base_volume: self.base_volume,
}
}
#[must_use]
pub fn rate(&self) -> f64 {
self.base_rate
}
#[must_use]
pub fn pitch(&self) -> f64 {
self.base_pitch
}
#[must_use]
pub fn volume(&self) -> f64 {
self.base_volume
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmphasisLevel {
None,
Reduced,
Moderate,
Strong,
}
pub struct MockTtsEngine {
sample_rate: u32,
}
impl MockTtsEngine {
#[must_use]
pub fn new(sample_rate: u32) -> Self {
Self { sample_rate }
}
fn estimate_samples(&self, text: &str, rate: f64) -> usize {
let word_count = text.split_whitespace().count();
let words_per_second = 2.5 * rate;
let duration_seconds = word_count as f64 / words_per_second;
(duration_seconds * self.sample_rate as f64) as usize
}
fn generate_tone(&self, duration_samples: usize, frequency: f64) -> Vec<f64> {
let mut samples = Vec::with_capacity(duration_samples);
let phase_increment = 2.0 * std::f64::consts::PI * frequency / self.sample_rate as f64;
for i in 0..duration_samples {
let phase = i as f64 * phase_increment;
let sample = (phase.sin() * 0.1).clamp(-0.1, 0.1);
samples.push(sample);
}
samples
}
}
impl TtsEngine for MockTtsEngine {
fn synthesize(&mut self, text: &str, config: &SynthesisConfig) -> AudioResult<Vec<f64>> {
if text.is_empty() {
return Ok(Vec::new());
}
let sample_count = self.estimate_samples(text, config.rate);
let mut samples = self.generate_tone(sample_count, 440.0);
for sample in &mut samples {
*sample *= config.volume;
}
Ok(samples)
}
fn estimate_duration(&self, text: &str, config: &SynthesisConfig) -> f64 {
let word_count = text.split_whitespace().count();
let words_per_second = 2.5 * config.rate;
word_count as f64 / words_per_second
}
fn supports_language(&self, _language: &LanguageTag) -> bool {
true
}
fn available_voices(&self, language: &LanguageTag) -> Vec<VoiceInfo> {
vec![
VoiceInfo::new("mock_male", "Mock Male", language.clone()),
VoiceInfo::new("mock_female", "Mock Female", language.clone()),
]
}
}
pub struct TtsSynthesizer<E: TtsEngine> {
engine: E,
config: SynthesisConfig,
cache: HashMap<String, Vec<f64>>,
max_cache_size: usize,
}
impl<E: TtsEngine> TtsSynthesizer<E> {
#[must_use]
pub fn new(engine: E, config: SynthesisConfig) -> Self {
Self {
engine,
config,
cache: HashMap::new(),
max_cache_size: 100,
}
}
pub fn set_config(&mut self, config: SynthesisConfig) {
self.config = config;
}
#[must_use]
pub fn config(&self) -> &SynthesisConfig {
&self.config
}
pub fn synthesize(&mut self, text: &str) -> AudioResult<Vec<f64>> {
if text.is_empty() {
return Ok(Vec::new());
}
let cache_key = self.make_cache_key(text);
if let Some(cached) = self.cache.get(&cache_key) {
return Ok(cached.clone());
}
let processed_text = if self.config.enable_ssml {
text.to_string()
} else {
self.config.pronunciation_dict.apply(text)
};
let samples = self.engine.synthesize(&processed_text, &self.config)?;
if self.cache.len() >= self.max_cache_size {
if let Some(key) = self.cache.keys().next().cloned() {
self.cache.remove(&key);
}
}
self.cache.insert(cache_key, samples.clone());
Ok(samples)
}
#[must_use]
pub fn estimate_duration(&self, text: &str) -> f64 {
self.engine.estimate_duration(text, &self.config)
}
pub fn clear_cache(&mut self) {
self.cache.clear();
}
#[must_use]
pub fn cache_size(&self) -> usize {
self.cache.len()
}
fn make_cache_key(&self, text: &str) -> String {
format!(
"{}:{}:{}:{}",
text, self.config.voice.id, self.config.rate, self.config.pitch_semitones
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_synthesis_config() {
let voice = VoiceInfo::default();
let config = SynthesisConfig::new(voice).with_rate(1.5);
assert!((config.rate - 1.5).abs() < f64::EPSILON);
}
#[test]
fn test_pronunciation_dict() {
let mut dict = PronunciationDictionary::new();
dict.add("NASA", "N-A-S-A");
assert_eq!(dict.get("nasa"), Some("N-A-S-A"));
assert_eq!(dict.len(), 1);
}
#[test]
fn test_pronunciation_apply() {
let mut dict = PronunciationDictionary::new();
dict.add("NASA", "N-A-S-A");
let result = dict.apply("NASA launched a rocket");
assert!(result.contains("N-A-S-A"));
}
#[test]
fn test_ssml_processor() {
let config = SynthesisConfig::default();
let processor = SsmlProcessor::new(config);
let ssml = "<speak>Hello world</speak>";
let segments = processor.parse(ssml).expect("parse should succeed");
assert!(!segments.is_empty());
assert_eq!(segments[0].text, "Hello world");
}
#[test]
fn test_prosody_control() {
let prosody = ProsodyControl::new(1.0, 0.0, 1.0);
let emphasized = prosody.with_emphasis(EmphasisLevel::Strong);
assert!(emphasized.pitch() > prosody.pitch());
}
#[test]
fn test_mock_tts_engine() {
let mut engine = MockTtsEngine::new(48000);
let config = SynthesisConfig::default();
let samples = engine
.synthesize("Hello world", &config)
.expect("should succeed");
assert!(!samples.is_empty());
let duration = engine.estimate_duration("Hello world", &config);
assert!(duration > 0.0);
}
#[test]
fn test_tts_synthesizer_cache() {
let engine = MockTtsEngine::new(48000);
let config = SynthesisConfig::default();
let mut synthesizer = TtsSynthesizer::new(engine, config);
let samples1 = synthesizer.synthesize("Hello").expect("should succeed");
assert!(!samples1.is_empty());
assert_eq!(synthesizer.cache_size(), 1);
let samples2 = synthesizer.synthesize("Hello").expect("should succeed");
assert_eq!(samples1.len(), samples2.len());
}
}