use crate::engine::{estimate_word_boundaries, TtsEngine};
use crate::types::{TtsError, TtsResult, Voice};
use std::sync::Mutex;
#[derive(Debug)]
pub struct SystemEngine {
conn: Mutex<Option<speech_dispatcher::Connection>>,
}
impl SystemEngine {
pub fn new() -> Self {
let conn = speech_dispatcher::Connection::open(
"rust-tts-wrapper",
"rust-tts-wrapper",
"rust-tts-wrapper",
speech_dispatcher::Mode::Threaded,
)
.ok();
SystemEngine {
conn: Mutex::new(conn),
}
}
}
fn rate_to_spd(rate: f32) -> i32 {
((rate.clamp(0.1, 10.0) - 1.0) * 100.0).round() as i32
}
fn pitch_to_spd(pitch: f32) -> i32 {
((pitch.clamp(0.1, 10.0) - 1.0) * 100.0).round() as i32
}
fn volume_to_spd(volume: f32) -> i32 {
((volume.clamp(0.0, 2.0) - 1.0) * 100.0).round() as i32
}
impl TtsEngine for SystemEngine {
fn speak(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
_on_audio: Option<crate::engine::OnAudioCallback>,
mut on_boundary: Option<crate::engine::OnBoundaryCallback>,
_on_mark: Option<crate::engine::OnMarkCallback>,
) -> TtsResult<()> {
let guard = self.conn.lock().unwrap();
let conn = guard
.as_ref()
.ok_or_else(|| TtsError("Speech dispatcher not connected".into()))?;
if let Some(v) = voice {
let _ = conn.set_synthesis_voice_all(v);
}
let _ = conn.set_voice_rate_all(rate_to_spd(rate));
let _ = conn.set_voice_pitch_all(pitch_to_spd(pitch));
let _ = conn.set_volume_all(volume_to_spd(volume));
conn.say(speech_dispatcher::Priority::Important, text);
if let Some(cb) = on_boundary.as_mut() {
let estimated = estimate_word_boundaries(text);
let mut search = crate::word_search::WordSearch::new(text);
for b in &estimated {
let (char_offset, char_len) = search.find_next(&b.text);
#[allow(clippy::cast_precision_loss)]
let start = b.offset as f32 / 1000.0;
#[allow(clippy::cast_precision_loss)]
let end = (b.offset + b.duration) as f32 / 1000.0;
cb(&b.text, start, end, char_offset.max(0), char_len, true); }
}
Ok(())
}
fn speak_sync(
&self,
text: &str,
voice: Option<&str>,
rate: f32,
pitch: f32,
volume: f32,
on_audio: Option<crate::engine::OnAudioCallback>,
on_boundary: Option<crate::engine::OnBoundaryCallback>,
on_mark: Option<crate::engine::OnMarkCallback>,
) -> TtsResult<()> {
self.speak(
text,
voice,
rate,
pitch,
volume,
on_audio,
on_boundary,
on_mark,
)
}
fn stop(&self) -> TtsResult<()> {
let guard = self.conn.lock().unwrap();
let conn = guard
.as_ref()
.ok_or_else(|| TtsError("Speech dispatcher not connected".into()))?;
conn.cancel()
.map_err(|e| TtsError(format!("Stop failed: {e}")))
}
fn pause(&self) -> TtsResult<()> {
let guard = self.conn.lock().unwrap();
if let Some(conn) = guard.as_ref() {
let _ = conn.pause_all();
}
Ok(())
}
fn resume(&self) -> TtsResult<()> {
let guard = self.conn.lock().unwrap();
if let Some(conn) = guard.as_ref() {
let _ = conn.resume_all();
}
Ok(())
}
fn get_voices(&self) -> TtsResult<Vec<Voice>> {
let guard = self.conn.lock().unwrap();
let conn = guard
.as_ref()
.ok_or_else(|| TtsError("Speech dispatcher not connected".into()))?;
let spd_voices = conn
.list_synthesis_voices()
.map_err(|e| TtsError(format!("Failed to list speech-dispatcher voices: {e}")))?;
let voices = spd_voices
.into_iter()
.map(|v| {
let lang = v.language.clone();
let iso639 = lang.split(['-', '_']).next().unwrap_or(&lang).to_string();
Voice {
id: v.name.clone(),
name: v.name,
gender: crate::types::Gender::Unknown,
provider: "system".to_string(),
language_codes: vec![crate::types::LanguageCode {
bcp47: lang.clone(),
iso639_3: iso639,
display: lang,
}],
}
})
.collect();
Ok(voices)
}
fn engine_id(&self) -> &'static str {
"system"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rate_to_spd_normal_is_zero() {
assert_eq!(rate_to_spd(1.0), 0);
}
#[test]
fn test_rate_to_spd_positive_when_faster() {
assert_eq!(rate_to_spd(1.5), 50);
assert!(rate_to_spd(1.1) > 0);
}
#[test]
fn test_rate_to_spd_negative_when_slower() {
assert_eq!(rate_to_spd(0.5), -50);
assert!(rate_to_spd(0.9) < 0);
}
#[test]
fn test_rate_to_spd_clamps_extremes() {
assert_eq!(rate_to_spd(100.0), rate_to_spd(10.0));
assert_eq!(rate_to_spd(0.0), rate_to_spd(0.1));
}
#[test]
fn test_pitch_to_spd_matches_rate_shape() {
assert_eq!(pitch_to_spd(1.0), 0);
assert_eq!(pitch_to_spd(1.5), 50);
assert_eq!(pitch_to_spd(0.5), -50);
}
#[test]
fn test_volume_to_spd_normal_is_zero() {
assert_eq!(volume_to_spd(1.0), 0);
}
#[test]
fn test_volume_to_spd_clamped_at_zero_and_two() {
assert_eq!(volume_to_spd(2.0), 100);
assert_eq!(volume_to_spd(0.0), -100);
assert_eq!(volume_to_spd(5.0), volume_to_spd(2.0));
}
}