1use crate::types::{SpeakOptions, TtsResult, Voice, WordBoundary};
4use std::fmt;
5
6pub type OnAudioCallback<'a> = &'a mut dyn FnMut(&[u8]);
8
9pub type OnBoundaryCallback<'a> = &'a mut dyn FnMut(&str, f32, f32, i32, i32, bool);
19
20pub type OnMarkCallback<'a> = &'a mut dyn FnMut(&str, f32, f32, i32);
26
27pub type OnStartCallback<'a> = &'a mut dyn FnMut();
29
30pub type OnEndCallback<'a> = &'a mut dyn FnMut();
32
33pub type OnErrorCallback<'a> = &'a mut dyn FnMut(&str);
35
36#[cfg(feature = "speechmarkdown")]
54#[must_use]
55pub fn preprocess_speech_markdown(text: &str, platform: &str) -> (String, bool) {
56 use speechmarkdown_rust::{Platform, SpeechMarkdownParser};
57
58 if text.trim_start().to_ascii_lowercase().starts_with("<speak") {
61 return (text.to_string(), true);
62 }
63
64 if !SpeechMarkdownParser::is_speech_markdown(text) {
65 return (text.to_string(), false);
66 }
67
68 let platform = match platform {
69 "azure" | "edge" => Platform::MicrosoftAzure,
73 "google" => Platform::GoogleAssistant,
74 "elevenlabs" => Platform::ElevenLabs,
78 "elevenlabs-v3" => Platform::ElevenLabsV3,
79 "gemini" => Platform::Gemini,
82
83 _ => Platform::AmazonAlexa,
85 };
86
87 let is_prompt_dialect = matches!(
88 platform,
89 Platform::ElevenLabs | Platform::ElevenLabsV3 | Platform::Gemini
90 );
91
92 match SpeechMarkdownParser::to_ssml(text, platform) {
93 Ok(ssml) => (ssml, !is_prompt_dialect),
96 Err(_) => (text.to_string(), false),
97 }
98}
99
100#[cfg(not(feature = "speechmarkdown"))]
101#[must_use]
102pub fn preprocess_speech_markdown(text: &str, _platform: &str) -> (String, bool) {
103 (text.to_string(), false)
104}
105
106#[must_use]
111pub fn strip_ssml_to_text(ssml: &str) -> String {
112 let mut out = String::with_capacity(ssml.len());
113 let mut in_tag = false;
114 for ch in ssml.chars() {
115 match ch {
116 '<' => in_tag = true,
117 '>' => in_tag = false,
118 c if !in_tag => out.push(c),
119 _ => {}
120 }
121 }
122 out.split_whitespace().collect::<Vec<_>>().join(" ")
123}
124
125#[must_use]
135pub fn unwrap_voice_tag(ssml: &str) -> (Option<String>, String) {
136 let Some(open_idx) = ssml.find("<voice ") else {
138 return (None, ssml.to_string());
139 };
140 let Some(tag_offset) = ssml[open_idx..].find('>') else {
141 return (None, ssml.to_string());
142 };
143 let tag_end = open_idx + tag_offset;
144 let tag_str = &ssml[open_idx..=tag_end];
145
146 let voice_name = extract_name_attr(tag_str);
147
148 let content_start = tag_end + 1;
150 let content_end = ssml.find("</voice>").unwrap_or(ssml.len());
151 let inner = &ssml[content_start..content_end];
152
153 (voice_name, format!("<speak>{inner}</speak>"))
154}
155
156fn extract_name_attr(tag: &str) -> Option<String> {
158 for quote in ['\'', '"'] {
159 let needle = format!("name={quote}");
160 if let Some(start) = tag.find(&needle) {
161 let val_start = start + needle.len();
162 if let Some(end) = tag[val_start..].find(quote) {
163 return Some(tag[val_start..val_start + end].to_string());
164 }
165 }
166 }
167 None
168}
169
170#[allow(clippy::missing_errors_doc)]
174pub trait TtsEngine: Send + Sync + fmt::Debug {
175 #[allow(clippy::too_many_arguments)]
177 #[allow(clippy::too_many_arguments)]
178 fn speak(
179 &self,
180 text: &str,
181 voice: Option<&str>,
182 rate: f32,
183 pitch: f32,
184 volume: f32,
185 on_audio: Option<OnAudioCallback>,
186 on_boundary: Option<OnBoundaryCallback>,
187 on_mark: Option<OnMarkCallback>,
188 ) -> TtsResult<()>;
189
190 fn speak_with_options(
192 &self,
193 text: &str,
194 options: Option<&SpeakOptions>,
195 on_audio: Option<OnAudioCallback>,
196 on_boundary: Option<OnBoundaryCallback>,
197 on_mark: Option<OnMarkCallback>,
198 ) -> TtsResult<()> {
199 let opts = options.cloned().unwrap_or_default();
200 self.speak(
201 text,
202 opts.voice.as_deref(),
203 opts.effective_rate(),
204 opts.effective_pitch(),
205 opts.effective_volume(),
206 on_audio,
207 on_boundary,
208 on_mark,
209 )
210 }
211
212 #[allow(clippy::too_many_arguments)]
214 #[allow(clippy::too_many_arguments)]
215 fn speak_sync(
216 &self,
217 text: &str,
218 voice: Option<&str>,
219 rate: f32,
220 pitch: f32,
221 volume: f32,
222 on_audio: Option<OnAudioCallback>,
223 on_boundary: Option<OnBoundaryCallback>,
224 on_mark: Option<OnMarkCallback>,
225 ) -> TtsResult<()>;
226
227 fn stop(&self) -> TtsResult<()>;
229
230 fn pause(&self) -> TtsResult<()> {
232 Ok(())
233 }
234
235 fn resume(&self) -> TtsResult<()> {
237 Ok(())
238 }
239
240 fn get_voices(&self) -> TtsResult<Vec<Voice>>;
242
243 fn engine_id(&self) -> &'static str;
245
246 fn check_credentials(&self) -> TtsResult<bool> {
249 match self.get_voices() {
250 Ok(_) => Ok(true),
251 Err(_) => Ok(false),
252 }
253 }
254
255 fn synth_to_bytes(
258 &self,
259 text: &str,
260 voice: Option<&str>,
261 rate: f32,
262 pitch: f32,
263 volume: f32,
264 ) -> TtsResult<Vec<u8>> {
265 let mut buf = Vec::new();
266 self.speak(
267 text,
268 voice,
269 rate,
270 pitch,
271 volume,
272 Some(&mut |chunk: &[u8]| {
273 buf.extend_from_slice(chunk);
274 }),
275 None,
276 None,
277 )?;
278 Ok(buf)
279 }
280
281 fn synth_to_bytes_with_options(
283 &self,
284 text: &str,
285 options: Option<&SpeakOptions>,
286 ) -> TtsResult<Vec<u8>> {
287 let opts = options.cloned().unwrap_or_default();
288 self.synth_to_bytes(
289 text,
290 opts.voice.as_deref(),
291 opts.effective_rate(),
292 opts.effective_pitch(),
293 opts.effective_volume(),
294 )
295 }
296
297 fn synth_with_boundaries(
299 &self,
300 text: &str,
301 voice: Option<&str>,
302 rate: f32,
303 pitch: f32,
304 volume: f32,
305 ) -> TtsResult<(Vec<u8>, Vec<WordBoundary>)> {
306 let audio = self.synth_to_bytes(text, voice, rate, pitch, volume)?;
307 let boundaries = estimate_word_boundaries(text);
308 Ok((audio, boundaries))
309 }
310}
311
312#[must_use]
316#[allow(clippy::cast_precision_loss)]
317pub fn estimate_word_boundaries(text: &str) -> Vec<WordBoundary> {
318 estimate_word_boundaries_with_wpm(text, 150.0)
319}
320
321#[must_use]
325#[allow(clippy::cast_precision_loss)]
326pub fn estimate_word_boundaries_with_wpm(text: &str, words_per_minute: f64) -> Vec<WordBoundary> {
327 const SENTENCE_GAP_MS: u64 = 350;
336
337 #[allow(clippy::cast_precision_loss)]
338 let ms_per_syllable = 60_000.0 / (words_per_minute * 1.55);
339
340 let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
341 if words.is_empty() {
342 return Vec::new();
343 }
344
345 let mut boundaries: Vec<WordBoundary> = Vec::with_capacity(words.len());
346 let mut current_ms: u64 = 0;
347
348 for (i, word) in words.iter().enumerate() {
349 if i > 0 && ends_sentence(words[i - 1]) {
350 current_ms += SENTENCE_GAP_MS;
351 }
352 let duration = ((word_weight(word) * ms_per_syllable) as u64).max(1);
353 boundaries.push(WordBoundary {
354 text: (*word).to_string(),
355 offset: current_ms,
356 duration,
357 estimated: true,
358 });
359 current_ms += duration;
360 }
361
362 boundaries
363}
364
365#[must_use]
368pub fn syllable_count(word: &str) -> usize {
369 let mut count = 0usize;
370 let mut in_vowel_run = false;
371 for ch in word.chars() {
372 if ch.is_ascii_digit() {
373 count += 1;
375 in_vowel_run = false;
376 } else {
377 let vowel = matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y');
378 if vowel && !in_vowel_run {
379 count += 1;
380 }
381 in_vowel_run = vowel;
382 }
383 }
384 count.max(1)
385}
386
387#[must_use]
390pub fn word_weight(word: &str) -> f64 {
391 let bare = word.trim_matches(|c: char| !c.is_alphanumeric());
392 f64::from(syllable_count(bare) as u32) + 0.3
393}
394
395#[must_use]
398pub fn trailing_pause_weight(word: &str) -> f64 {
399 let trimmed = word
401 .trim_end()
402 .trim_end_matches(['"', '\'', '”', '’', ')', ']', '»']);
403 let Some(last) = trimmed.chars().last() else {
404 return 0.0;
405 };
406 match last {
407 '.' | '!' | '?' | '…' => 1.4, ';' | ':' | '—' | '–' => 0.75,
409 ',' => 0.55,
410 _ => 0.0,
411 }
412}
413
414#[must_use]
420#[allow(clippy::cast_precision_loss)]
421pub fn weighted_boundaries_scaled(text: &str, total_secs: f32) -> Vec<WordBoundary> {
422 let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
423 if words.is_empty() || total_secs <= 0.0 {
424 return Vec::new();
425 }
426
427 let mut before: Vec<f64> = Vec::with_capacity(words.len());
429 let mut after: Vec<f64> = Vec::with_capacity(words.len());
430 let mut cum = 0.0f64;
431 for (i, word) in words.iter().enumerate() {
432 if i > 0 {
433 cum += trailing_pause_weight(words[i - 1]);
434 }
435 before.push(cum);
436 cum += word_weight(word);
437 after.push(cum);
438 }
439 let total_w = cum.max(1e-9);
440
441 words
442 .iter()
443 .enumerate()
444 .map(|(i, word)| {
445 let start = f64::from(total_secs) * before[i] / total_w;
446 let end = f64::from(total_secs) * after[i] / total_w;
447 WordBoundary {
448 text: (*word).to_string(),
449 offset: (start * 1000.0) as u64,
450 duration: (((end - start) * 1000.0) as u64).max(1),
451 estimated: true,
452 }
453 })
454 .collect()
455}
456
457fn ends_sentence(word: &str) -> bool {
462 let trimmed = word.trim_end_matches(['"', '\'', ')', ']', '”', '’']);
463 trimmed.ends_with('.')
464 || trimmed.ends_with('!')
465 || trimmed.ends_with('?')
466 || trimmed.ends_with('…')
467}
468
469#[cfg(test)]
470mod tests {
471 #[test]
472 fn estimator_adds_sentence_gap_between_sentences() {
473 let b = estimate_word_boundaries("One. Two words here.");
474 assert_eq!(b.len(), 4);
475 assert_eq!(b[0].offset, 0);
476 assert!(b[1].offset >= b[0].offset + b[0].duration + 350);
479 assert_eq!(b[2].offset, b[1].offset + b[1].duration);
482 }
483
484 #[test]
485 fn estimator_no_gap_without_sentence_punctuation() {
486 let b = estimate_word_boundaries("one two three");
487 for w in b.windows(2) {
488 assert_eq!(w[1].offset, w[0].offset + w[0].duration);
489 }
490 }
491
492 #[test]
493 fn estimator_gap_applies_before_closing_quote() {
494 let b = estimate_word_boundaries("He said \"stop.\" Then left.");
495 let then = &b[3];
497 let stop = &b[2];
498 assert!(then.offset >= stop.offset + stop.duration + 350);
499 }
500
501 #[test]
502 fn estimator_inline_punctuation_gets_no_gap() {
503 let b = estimate_word_boundaries("first, second; third:");
504 for w in b.windows(2) {
505 assert_eq!(w[1].offset, w[0].offset + w[0].duration);
506 }
507 }
508
509 use super::*;
510
511 #[test]
512 fn test_strip_ssml_to_text_basic() {
513 assert_eq!(
514 strip_ssml_to_text("<speak>Hello world</speak>"),
515 "Hello world"
516 );
517 }
518
519 #[test]
520 fn test_strip_ssml_to_text_with_tags() {
521 let ssml = "<speak><voice name='en-US-Aria'>Hello <phoneme alphabet='ipa' ph='wɜːld'>world</phoneme></voice></speak>";
522 assert_eq!(strip_ssml_to_text(ssml), "Hello world");
523 }
524
525 #[test]
526 fn test_strip_ssml_to_text_collapses_whitespace() {
527 assert_eq!(
528 strip_ssml_to_text("<speak>\n Hello\n world\n</speak>"),
529 "Hello world"
530 );
531 }
532
533 #[test]
534 fn test_strip_ssml_to_text_plain_passthrough() {
535 assert_eq!(strip_ssml_to_text("just plain text"), "just plain text");
536 }
537
538 #[test]
539 fn test_unwrap_voice_tag_extracts_voice_and_inner() {
540 let ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis'>\
541 <voice name='en-US-Wavenet-D'>Hello <prosody rate='+20%'>fast</prosody></voice>\
542 </speak>";
543 let (voice, inner) = unwrap_voice_tag(ssml);
544 assert_eq!(voice.as_deref(), Some("en-US-Wavenet-D"));
545 assert!(inner.starts_with("<speak>"));
546 assert!(inner.contains("Hello"));
547 assert!(inner.contains("<prosody"));
548 assert!(!inner.contains("<voice"));
549 }
550
551 #[test]
552 fn test_unwrap_voice_tag_double_quotes() {
553 let ssml = "<speak><voice name=\"en-US-AriaNeural\">Hi</voice></speak>";
554 let (voice, inner) = unwrap_voice_tag(ssml);
555 assert_eq!(voice.as_deref(), Some("en-US-AriaNeural"));
556 assert!(inner.contains("Hi"));
557 }
558
559 #[test]
560 fn test_unwrap_voice_tag_no_voice_returns_original() {
561 let ssml = "<speak>Hello world</speak>";
562 let (voice, inner) = unwrap_voice_tag(ssml);
563 assert!(voice.is_none());
564 assert_eq!(inner, ssml);
565 }
566}
567
568#[cfg(test)]
569mod weighted_timing_tests {
570 use super::*;
571
572 #[test]
573 fn syllable_counts() {
574 assert_eq!(syllable_count("friend"), 1);
575 assert_eq!(syllable_count("friendship"), 2);
576 assert_eq!(syllable_count("banana"), 3);
577 assert_eq!(syllable_count("strength"), 1);
578 assert_eq!(syllable_count("2026"), 4); assert_eq!(syllable_count("hmm"), 1); }
581
582 #[test]
583 fn scaled_covers_exactly_and_orders_monotonically() {
584 let b = weighted_boundaries_scaled("Hello, this is a timing test.", 10.0);
585 assert_eq!(b.len(), 6);
586 assert_eq!(b[0].offset, 0);
587 let last = b.last().unwrap();
588 assert!(last.offset + last.duration >= 9_990);
590 for w in b.windows(2) {
591 assert!(w[1].offset >= w[0].offset + w[0].duration - 1);
592 }
593 }
594
595 #[test]
596 fn long_words_get_more_time_than_short() {
597 let b = weighted_boundaries_scaled("a antidisestablishmentarianism", 10.0);
598 let short = b[0].duration;
599 let long = b[1].duration;
600 assert!(long > short * 4, "long {long} vs short {short}");
601 }
602
603 #[test]
604 fn punctuation_beats_pause() {
605 let b = weighted_boundaries_scaled("Hello, world", 10.0);
607 let gap = b[1].offset - (b[0].offset + b[0].duration);
608 assert!(gap > 10, "expected comma pause, got {gap} ms");
609 let s = weighted_boundaries_scaled("Hello. World", 10.0);
611 let sgap = s[1].offset - (s[0].offset + s[0].duration);
612 assert!(sgap > gap);
613 }
614
615 #[test]
616 fn estimator_long_word_longer_than_short() {
617 let b = estimate_word_boundaries("strength audio");
618 assert!(b[1].duration > b[0].duration);
620 }
621}