use std::io::Write;
use std::path::Path;
use std::process::{Child, Command, Stdio};
#[derive(Debug, Default)]
pub(super) struct Say {
child: Option<Child>,
}
impl Say {
pub(super) fn available() -> Result<(), &'static str> {
if !cfg!(target_os = "macos") {
return Err("TTS is macOS-only in 1.2.9");
}
if !Path::new("/usr/bin/say").exists() {
return Err("/usr/bin/say not found");
}
Ok(())
}
pub(super) fn speak(
&mut self,
text: &str,
voice: &str,
rate_wpm: Option<u16>,
) -> std::io::Result<()> {
self.stop();
let mut cmd = Command::new("/usr/bin/say");
if !voice.is_empty() {
cmd.arg("-v").arg(voice);
}
if let Some(r) = rate_wpm {
cmd.arg("-r").arg(r.to_string());
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(text.as_bytes())?;
}
self.child = Some(child);
Ok(())
}
pub(super) fn is_speaking(&mut self) -> bool {
let Some(child) = self.child.as_mut() else {
return false;
};
match child.try_wait() {
Ok(None) => true,
Ok(Some(_)) => {
self.child = None;
false
}
Err(_) => false,
}
}
pub(super) fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
pub(super) fn list_voices() -> Vec<(String, String, String)> {
let output = match Command::new("/usr/bin/say")
.arg("-v")
.arg("?")
.output()
{
Ok(o) => o,
Err(_) => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
let mut out = Vec::new();
for line in stdout.lines() {
let (head, sample) = match line.split_once("# ") {
Some((a, b)) => (a.trim_end(), b.to_string()),
None => (line.trim_end(), String::new()),
};
let mut parts: Vec<&str> = head.split_whitespace().collect();
if parts.is_empty() {
continue;
}
let locale = parts.pop().unwrap_or("").to_string();
let name = parts.join(" ");
if name.is_empty() {
continue;
}
out.push((name, locale, sample));
}
out
}
pub(super) fn pick_voice(needle: &str) -> Option<String> {
if needle.is_empty() {
return None;
}
let needle_lc = needle.to_lowercase();
let voices = Self::list_voices();
let mut best: Option<(String, bool, usize)> = None;
for (name, _locale, _sample) in voices {
if !name.to_lowercase().contains(&needle_lc) {
continue;
}
let lc = name.to_lowercase();
let enhanced =
lc.contains("enhanced") || lc.contains("premium");
let len = name.chars().count();
let candidate = (name.clone(), enhanced, len);
best = match best {
None => Some(candidate),
Some(prev) => {
let prev_score = (prev.1, std::cmp::Reverse(prev.2));
let new_score = (candidate.1, std::cmp::Reverse(candidate.2));
if new_score > prev_score {
Some(candidate)
} else {
Some(prev)
}
}
};
}
best.map(|(n, _, _)| n)
}
}
impl Drop for Say {
fn drop(&mut self) {
self.stop();
}
}