use crate::types::{SpeakOptions, TtsResult, Voice, WordBoundary};
use std::fmt;
pub type OnAudioCallback<'a> = &'a mut dyn FnMut(&[u8]);
pub type OnBoundaryCallback<'a> = &'a mut dyn FnMut(&str, f32, f32, i32, i32, bool);
pub type OnMarkCallback<'a> = &'a mut dyn FnMut(&str, f32, f32, i32);
pub type OnStartCallback<'a> = &'a mut dyn FnMut();
pub type OnEndCallback<'a> = &'a mut dyn FnMut();
pub type OnErrorCallback<'a> = &'a mut dyn FnMut(&str);
#[cfg(feature = "speechmarkdown")]
#[must_use]
pub fn preprocess_speech_markdown(text: &str, platform: &str) -> (String, bool) {
use speechmarkdown_rust::{Platform, SpeechMarkdownParser};
if text.trim_start().to_ascii_lowercase().starts_with("<speak") {
return (text.to_string(), true);
}
if !SpeechMarkdownParser::is_speech_markdown(text) {
return (text.to_string(), false);
}
let platform = match platform {
"azure" | "edge" => Platform::MicrosoftAzure,
"google" => Platform::GoogleAssistant,
"elevenlabs" => Platform::ElevenLabs,
"elevenlabs-v3" => Platform::ElevenLabsV3,
"gemini" => Platform::Gemini,
_ => Platform::AmazonAlexa,
};
let is_prompt_dialect = matches!(
platform,
Platform::ElevenLabs | Platform::ElevenLabsV3 | Platform::Gemini
);
match SpeechMarkdownParser::to_ssml(text, platform) {
Ok(ssml) => (ssml, !is_prompt_dialect),
Err(_) => (text.to_string(), false),
}
}
#[cfg(not(feature = "speechmarkdown"))]
#[must_use]
pub fn preprocess_speech_markdown(text: &str, _platform: &str) -> (String, bool) {
(text.to_string(), false)
}
#[must_use]
pub fn strip_ssml_to_text(ssml: &str) -> String {
let mut out = String::with_capacity(ssml.len());
let mut in_tag = false;
for ch in ssml.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
c if !in_tag => out.push(c),
_ => {}
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[must_use]
pub fn unwrap_voice_tag(ssml: &str) -> (Option<String>, String) {
let Some(open_idx) = ssml.find("<voice ") else {
return (None, ssml.to_string());
};
let Some(tag_offset) = ssml[open_idx..].find('>') else {
return (None, ssml.to_string());
};
let tag_end = open_idx + tag_offset;
let tag_str = &ssml[open_idx..=tag_end];
let voice_name = extract_name_attr(tag_str);
let content_start = tag_end + 1;
let content_end = ssml.find("</voice>").unwrap_or(ssml.len());
let inner = &ssml[content_start..content_end];
(voice_name, format!("<speak>{inner}</speak>"))
}
fn extract_name_attr(tag: &str) -> Option<String> {
for quote in ['\'', '"'] {
let needle = format!("name={quote}");
if let Some(start) = tag.find(&needle) {
let val_start = start + needle.len();
if let Some(end) = tag[val_start..].find(quote) {
return Some(tag[val_start..val_start + end].to_string());
}
}
}
None
}
#[allow(clippy::missing_errors_doc)]
pub trait TtsEngine: Send + Sync + fmt::Debug {
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn speak(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
on_audio: Option<OnAudioCallback>,
on_boundary: Option<OnBoundaryCallback>,
on_mark: Option<OnMarkCallback>,
) -> TtsResult<()>;
fn speak_with_options(
&self,
text: &str,
options: Option<&SpeakOptions>,
on_audio: Option<OnAudioCallback>,
on_boundary: Option<OnBoundaryCallback>,
on_mark: Option<OnMarkCallback>,
) -> TtsResult<()> {
let opts = options.cloned().unwrap_or_default();
self.speak(
text,
opts.voice.as_deref(),
opts.effective_rate(),
opts.effective_pitch(),
opts.effective_volume(),
on_audio,
on_boundary,
on_mark,
)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments)]
fn speak_sync(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
on_audio: Option<OnAudioCallback>,
on_boundary: Option<OnBoundaryCallback>,
on_mark: Option<OnMarkCallback>,
) -> TtsResult<()>;
fn stop(&self) -> TtsResult<()>;
fn pause(&self) -> TtsResult<()> {
Ok(())
}
fn resume(&self) -> TtsResult<()> {
Ok(())
}
fn get_voices(&self) -> TtsResult<Vec<Voice>>;
fn engine_id(&self) -> &'static str;
fn check_credentials(&self) -> TtsResult<bool> {
match self.get_voices() {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
}
fn synth_to_bytes(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
) -> TtsResult<Vec<u8>> {
let mut buf = Vec::new();
self.speak(
text,
voice,
rate,
pitch,
volume,
Some(&mut |chunk: &[u8]| {
buf.extend_from_slice(chunk);
}),
None,
None,
)?;
Ok(buf)
}
fn synth_to_bytes_with_options(
&self,
text: &str,
options: Option<&SpeakOptions>,
) -> TtsResult<Vec<u8>> {
let opts = options.cloned().unwrap_or_default();
self.synth_to_bytes(
text,
opts.voice.as_deref(),
opts.effective_rate(),
opts.effective_pitch(),
opts.effective_volume(),
)
}
fn synth_with_boundaries(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
) -> TtsResult<(Vec<u8>, Vec<WordBoundary>)> {
let audio = self.synth_to_bytes(text, voice, rate, pitch, volume)?;
let boundaries = estimate_word_boundaries(text);
Ok((audio, boundaries))
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn estimate_word_boundaries(text: &str) -> Vec<WordBoundary> {
estimate_word_boundaries_with_wpm(text, 150.0)
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn estimate_word_boundaries_with_wpm(text: &str, words_per_minute: f64) -> Vec<WordBoundary> {
const SENTENCE_GAP_MS: u64 = 350;
#[allow(clippy::cast_precision_loss)]
let ms_per_syllable = 60_000.0 / (words_per_minute * 1.55);
let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
if words.is_empty() {
return Vec::new();
}
let mut boundaries: Vec<WordBoundary> = Vec::with_capacity(words.len());
let mut current_ms: u64 = 0;
for (i, word) in words.iter().enumerate() {
if i > 0 && ends_sentence(words[i - 1]) {
current_ms += SENTENCE_GAP_MS;
}
let duration = ((word_weight(word) * ms_per_syllable) as u64).max(1);
boundaries.push(WordBoundary {
text: (*word).to_string(),
offset: current_ms,
duration,
estimated: true,
});
current_ms += duration;
}
boundaries
}
#[must_use]
pub fn syllable_count(word: &str) -> usize {
let mut count = 0usize;
let mut in_vowel_run = false;
for ch in word.chars() {
if ch.is_ascii_digit() {
count += 1;
in_vowel_run = false;
} else {
let vowel = matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u' | 'y');
if vowel && !in_vowel_run {
count += 1;
}
in_vowel_run = vowel;
}
}
count.max(1)
}
#[must_use]
pub fn word_weight(word: &str) -> f64 {
let bare = word.trim_matches(|c: char| !c.is_alphanumeric());
f64::from(syllable_count(bare) as u32) + 0.3
}
#[must_use]
pub fn trailing_pause_weight(word: &str) -> f64 {
let trimmed = word
.trim_end()
.trim_end_matches(['"', '\'', '”', '’', ')', ']', '»']);
let Some(last) = trimmed.chars().last() else {
return 0.0;
};
match last {
'.' | '!' | '?' | '…' => 1.4, ';' | ':' | '—' | '–' => 0.75,
',' => 0.55,
_ => 0.0,
}
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn weighted_boundaries_scaled(text: &str, total_secs: f32) -> Vec<WordBoundary> {
let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
if words.is_empty() || total_secs <= 0.0 {
return Vec::new();
}
let mut before: Vec<f64> = Vec::with_capacity(words.len());
let mut after: Vec<f64> = Vec::with_capacity(words.len());
let mut cum = 0.0f64;
for (i, word) in words.iter().enumerate() {
if i > 0 {
cum += trailing_pause_weight(words[i - 1]);
}
before.push(cum);
cum += word_weight(word);
after.push(cum);
}
let total_w = cum.max(1e-9);
words
.iter()
.enumerate()
.map(|(i, word)| {
let start = f64::from(total_secs) * before[i] / total_w;
let end = f64::from(total_secs) * after[i] / total_w;
WordBoundary {
text: (*word).to_string(),
offset: (start * 1000.0) as u64,
duration: (((end - start) * 1000.0) as u64).max(1),
estimated: true,
}
})
.collect()
}
fn ends_sentence(word: &str) -> bool {
let trimmed = word.trim_end_matches(['"', '\'', ')', ']', '”', '’']);
trimmed.ends_with('.')
|| trimmed.ends_with('!')
|| trimmed.ends_with('?')
|| trimmed.ends_with('…')
}
#[cfg(test)]
mod tests {
#[test]
fn estimator_adds_sentence_gap_between_sentences() {
let b = estimate_word_boundaries("One. Two words here.");
assert_eq!(b.len(), 4);
assert_eq!(b[0].offset, 0);
assert!(b[1].offset >= b[0].offset + b[0].duration + 350);
assert_eq!(b[2].offset, b[1].offset + b[1].duration);
}
#[test]
fn estimator_no_gap_without_sentence_punctuation() {
let b = estimate_word_boundaries("one two three");
for w in b.windows(2) {
assert_eq!(w[1].offset, w[0].offset + w[0].duration);
}
}
#[test]
fn estimator_gap_applies_before_closing_quote() {
let b = estimate_word_boundaries("He said \"stop.\" Then left.");
let then = &b[3];
let stop = &b[2];
assert!(then.offset >= stop.offset + stop.duration + 350);
}
#[test]
fn estimator_inline_punctuation_gets_no_gap() {
let b = estimate_word_boundaries("first, second; third:");
for w in b.windows(2) {
assert_eq!(w[1].offset, w[0].offset + w[0].duration);
}
}
use super::*;
#[test]
fn test_strip_ssml_to_text_basic() {
assert_eq!(
strip_ssml_to_text("<speak>Hello world</speak>"),
"Hello world"
);
}
#[test]
fn test_strip_ssml_to_text_with_tags() {
let ssml = "<speak><voice name='en-US-Aria'>Hello <phoneme alphabet='ipa' ph='wɜːld'>world</phoneme></voice></speak>";
assert_eq!(strip_ssml_to_text(ssml), "Hello world");
}
#[test]
fn test_strip_ssml_to_text_collapses_whitespace() {
assert_eq!(
strip_ssml_to_text("<speak>\n Hello\n world\n</speak>"),
"Hello world"
);
}
#[test]
fn test_strip_ssml_to_text_plain_passthrough() {
assert_eq!(strip_ssml_to_text("just plain text"), "just plain text");
}
#[test]
fn test_unwrap_voice_tag_extracts_voice_and_inner() {
let ssml = "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis'>\
<voice name='en-US-Wavenet-D'>Hello <prosody rate='+20%'>fast</prosody></voice>\
</speak>";
let (voice, inner) = unwrap_voice_tag(ssml);
assert_eq!(voice.as_deref(), Some("en-US-Wavenet-D"));
assert!(inner.starts_with("<speak>"));
assert!(inner.contains("Hello"));
assert!(inner.contains("<prosody"));
assert!(!inner.contains("<voice"));
}
#[test]
fn test_unwrap_voice_tag_double_quotes() {
let ssml = "<speak><voice name=\"en-US-AriaNeural\">Hi</voice></speak>";
let (voice, inner) = unwrap_voice_tag(ssml);
assert_eq!(voice.as_deref(), Some("en-US-AriaNeural"));
assert!(inner.contains("Hi"));
}
#[test]
fn test_unwrap_voice_tag_no_voice_returns_original() {
let ssml = "<speak>Hello world</speak>";
let (voice, inner) = unwrap_voice_tag(ssml);
assert!(voice.is_none());
assert_eq!(inner, ssml);
}
}
#[cfg(test)]
mod weighted_timing_tests {
use super::*;
#[test]
fn syllable_counts() {
assert_eq!(syllable_count("friend"), 1);
assert_eq!(syllable_count("friendship"), 2);
assert_eq!(syllable_count("banana"), 3);
assert_eq!(syllable_count("strength"), 1);
assert_eq!(syllable_count("2026"), 4); assert_eq!(syllable_count("hmm"), 1); }
#[test]
fn scaled_covers_exactly_and_orders_monotonically() {
let b = weighted_boundaries_scaled("Hello, this is a timing test.", 10.0);
assert_eq!(b.len(), 6);
assert_eq!(b[0].offset, 0);
let last = b.last().unwrap();
assert!(last.offset + last.duration >= 9_990);
for w in b.windows(2) {
assert!(w[1].offset >= w[0].offset + w[0].duration - 1);
}
}
#[test]
fn long_words_get_more_time_than_short() {
let b = weighted_boundaries_scaled("a antidisestablishmentarianism", 10.0);
let short = b[0].duration;
let long = b[1].duration;
assert!(long > short * 4, "long {long} vs short {short}");
}
#[test]
fn punctuation_beats_pause() {
let b = weighted_boundaries_scaled("Hello, world", 10.0);
let gap = b[1].offset - (b[0].offset + b[0].duration);
assert!(gap > 10, "expected comma pause, got {gap} ms");
let s = weighted_boundaries_scaled("Hello. World", 10.0);
let sgap = s[1].offset - (s[0].offset + s[0].duration);
assert!(sgap > gap);
}
#[test]
fn estimator_long_word_longer_than_short() {
let b = estimate_word_boundaries("strength audio");
assert!(b[1].duration > b[0].duration);
}
}