use std::path::Path;
use crate::config::TtsConfig;
use super::piper::PiperEngine;
use super::say::Say;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EngineKind {
System,
Piper,
Disabled,
}
impl EngineKind {
pub(crate) fn label(self) -> &'static str {
match self {
Self::System => "system",
Self::Piper => "piper",
Self::Disabled => "disabled",
}
}
}
#[derive(Debug)]
pub(crate) enum TtsEngine {
System(Say),
#[allow(dead_code)] Piper(PiperEngine),
Disabled(String),
}
impl Default for TtsEngine {
fn default() -> Self {
Self::Disabled("TTS not yet initialised".into())
}
}
impl TtsEngine {
pub(crate) fn resolve(cfg: &TtsConfig, project_root: &Path) -> Self {
if !cfg.enabled {
return Self::Disabled(
"TTS is disabled (set editor.tts.enabled = true)".into(),
);
}
let engine = cfg.engine.trim().to_ascii_lowercase();
match engine.as_str() {
"system" => Self::System(Say::default()),
"piper" => match PiperEngine::new(cfg, project_root) {
Ok(piper) => Self::Piper(piper),
Err(reason) => Self::Disabled(reason.to_user_message()),
},
_ => match PiperEngine::new(cfg, project_root) {
Ok(piper) => Self::Piper(piper),
Err(_piper_err) => {
Self::System(Say::default())
}
},
}
}
pub(crate) fn kind(&self) -> EngineKind {
match self {
Self::System(_) => EngineKind::System,
Self::Piper(_) => EngineKind::Piper,
Self::Disabled(_) => EngineKind::Disabled,
}
}
pub(crate) fn label(&self) -> &'static str {
self.kind().label()
}
pub(crate) fn is_ready(&self) -> Result<(), String> {
match self {
Self::System(_) => Say::available().map_err(|s| s.to_string()),
Self::Piper(_) => {
Ok(())
}
Self::Disabled(reason) => Err(reason.clone()),
}
}
pub(crate) fn resolve_voice(&self, needle: &str) -> Option<String> {
match self {
Self::System(_) => Say::pick_voice(needle),
Self::Piper(p) => Some(p.resolve_voice(needle)),
Self::Disabled(_) => None,
}
}
pub(crate) fn speak(
&mut self,
text: &str,
voice: &str,
rate_wpm: Option<u16>,
) -> Result<(), String> {
match self {
Self::System(say) => say
.speak(text, voice, rate_wpm)
.map_err(|e| format!("subprocess spawn failed: {e}")),
Self::Piper(p) => p.speak(text, voice, rate_wpm),
Self::Disabled(reason) => Err(reason.clone()),
}
}
pub(crate) fn speak_to_file_blocking(
&mut self,
text: &str,
voice: &str,
rate_wpm: Option<u16>,
dest: &Path,
timeout: std::time::Duration,
) -> Result<u64, String> {
match self {
Self::System(_) => Say::speak_to_file_blocking(
text, voice, rate_wpm, dest, timeout,
),
Self::Piper(p) => p.speak_to_file_blocking(
text, voice, rate_wpm, dest, timeout,
),
Self::Disabled(reason) => Err(reason.clone()),
}
}
pub(crate) fn is_speaking(&mut self) -> bool {
match self {
Self::System(say) => say.is_speaking(),
Self::Piper(p) => p.is_speaking(),
Self::Disabled(_) => false,
}
}
pub(crate) fn stop(&mut self) {
match self {
Self::System(say) => say.stop(),
Self::Piper(p) => p.stop(),
Self::Disabled(_) => {}
}
}
}
const _ASSERT_SEND_SYNC: fn() = || {
fn assert<T: Send + Sync>() {}
assert::<TtsEngine>();
assert::<EngineKind>();
};
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(engine: &str, enabled: bool) -> TtsConfig {
let mut c = TtsConfig::default();
c.enabled = enabled;
c.engine = engine.into();
c.binary_path = Some("/__inkhaven_test_no_such_piper__".into());
c.auto_download_binary = false;
c
}
#[test]
fn disabled_when_master_switch_off() {
let cfg = cfg_with("auto", false);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert_eq!(eng.kind(), EngineKind::Disabled);
assert!(eng.is_ready().is_err());
let msg = eng.is_ready().unwrap_err();
assert!(
msg.contains("editor.tts.enabled"),
"expected disabled reason to reference the master switch, got: {msg}",
);
}
#[test]
fn auto_falls_through_to_system_when_piper_unresolvable() {
let cfg = cfg_with("auto", true);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert_eq!(eng.kind(), EngineKind::System);
}
#[test]
fn forced_system_resolves_to_system() {
let cfg = cfg_with("system", true);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert_eq!(eng.kind(), EngineKind::System);
}
#[test]
fn forced_piper_collapses_to_disabled_when_binary_missing() {
let cfg = cfg_with("piper", true);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert_eq!(eng.kind(), EngineKind::Disabled);
let msg = eng.is_ready().unwrap_err();
assert!(
msg.contains("tts.binary_path") || msg.contains("Piper binary"),
"expected diagnostic to reference binary path, got: {msg}",
);
}
#[cfg(unix)]
#[test]
fn auto_resolves_to_piper_when_binary_present() {
let bin_dir = tempfile::tempdir().unwrap();
let bin = bin_dir.path().join("piper");
std::fs::write(&bin, b"#!/bin/sh\nexit 0\n").unwrap();
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&bin).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin, perms).unwrap();
let project = tempfile::tempdir().unwrap();
let mut cfg = TtsConfig::default();
cfg.enabled = true;
cfg.engine = "auto".into();
cfg.binary_path = Some(bin.to_string_lossy().to_string());
cfg.auto_download_binary = false;
let eng = TtsEngine::resolve(&cfg, project.path());
assert_eq!(eng.kind(), EngineKind::Piper);
}
#[test]
fn unknown_engine_string_treated_as_auto() {
let cfg = cfg_with("nonsense-value", true);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert_eq!(eng.kind(), EngineKind::System);
}
#[test]
fn engine_kind_label_round_trip() {
assert_eq!(EngineKind::System.label(), "system");
assert_eq!(EngineKind::Piper.label(), "piper");
assert_eq!(EngineKind::Disabled.label(), "disabled");
}
#[test]
fn label_on_engine_matches_kind() {
let cfg = cfg_with("auto", true);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert_eq!(eng.label(), eng.kind().label());
}
#[test]
fn disabled_engine_short_circuits_speak() {
let cfg = cfg_with("auto", false);
let mut eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
let err = eng
.speak("hello", "", None)
.expect_err("disabled engine must not speak");
assert!(err.contains("editor.tts.enabled"), "got: {err}");
assert!(!eng.is_speaking());
eng.stop();
}
#[test]
fn disabled_engine_short_circuits_save() {
let cfg = cfg_with("auto", false);
let mut eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
let dest = std::env::temp_dir().join("inkhaven-tts-t1-disabled.wav");
let err = eng
.speak_to_file_blocking(
"hello",
"",
None,
&dest,
std::time::Duration::from_secs(1),
)
.expect_err("disabled engine must not save");
assert!(err.contains("editor.tts.enabled"), "got: {err}");
}
#[test]
fn default_engine_is_disabled() {
let eng = TtsEngine::default();
assert_eq!(eng.kind(), EngineKind::Disabled);
}
#[test]
fn voice_resolution_for_disabled_returns_none() {
let cfg = cfg_with("auto", false);
let eng = TtsEngine::resolve(&cfg, std::env::temp_dir().as_path());
assert!(eng.resolve_voice("anything").is_none());
}
}