pub mod gemini;
pub mod openai;
pub mod playback;
use anyhow::Result;
use tokio_util::sync::CancellationToken;
use crate::shared::config::{TtsMode, TtsSettings};
#[derive(Debug, Clone, PartialEq)]
pub enum AudioClip {
Pcm {
sample_rate: u32,
channels: u16,
bytes: Vec<u8>,
},
Encoded(Vec<u8>),
}
impl AudioClip {
pub fn is_empty(&self) -> bool {
match self {
AudioClip::Pcm { bytes, .. } => bytes.len() < 2,
AudioClip::Encoded(bytes) => bytes.is_empty(),
}
}
}
#[async_trait::async_trait]
pub trait TtsEngine: Send + Sync {
async fn synthesize(&self, text: &str, cancel: &CancellationToken) -> Result<AudioClip>;
fn max_input_chars(&self) -> usize;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TtsSetupError {
Model,
ApiKey,
Url,
}
pub type TtsEnginePair = (Box<dyn TtsEngine>, Option<Box<dyn TtsEngine>>);
pub fn engines_from_config(
tts: &TtsSettings,
stored_key: Option<String>,
) -> std::result::Result<TtsEnginePair, TtsSetupError> {
let (assistant_voice, user_voice) = tts.active_voices();
let assistant = build_engine(tts, stored_key.clone(), None)?;
let user = match user_voice {
Some(uv) if Some(uv) != assistant_voice => {
Some(build_engine(tts, stored_key, Some(uv.to_string()))?)
}
_ => None,
};
Ok((assistant, user))
}
fn build_engine(
tts: &TtsSettings,
stored_key: Option<String>,
voice_override: Option<String>,
) -> std::result::Result<Box<dyn TtsEngine>, TtsSetupError> {
let speed = tts.speed;
match tts.mode {
TtsMode::OpenAi | TtsMode::Gemini => {
let cloud = tts.cloud().ok_or(TtsSetupError::Model)?;
let model = non_empty(cloud.model_name.clone()).ok_or(TtsSetupError::Model)?;
let key = resolve_key(stored_key, cloud.api_key_env.as_deref())
.ok_or(TtsSetupError::ApiKey)?;
let provider = tts.mode.cloud_provider().ok_or(TtsSetupError::Model)?;
let base = non_empty(cloud.url.clone())
.unwrap_or_else(|| provider.chat_base_url().to_string());
let voice = voice_override.or_else(|| non_empty(cloud.voice.clone()));
let instructions = non_empty(cloud.instructions.clone());
Ok(match tts.mode {
TtsMode::Gemini => Box::new(gemini::GeminiTts::new(
base,
key,
model,
voice,
instructions,
)),
_ => Box::new(openai::OpenAiTts::cloud(
base,
key,
model,
voice,
instructions,
speed,
)),
})
}
TtsMode::External => {
let url = non_empty(tts.external.url.clone()).ok_or(TtsSetupError::Url)?;
Ok(Box::new(openai::OpenAiTts::external(
url,
resolve_key(stored_key, tts.external.api_key_env.as_deref()),
non_empty(tts.external.model_name.clone()),
voice_override.or_else(|| non_empty(tts.external.voice.clone())),
speed,
)))
}
}
}
fn resolve_key(stored: Option<String>, api_key_env: Option<&str>) -> Option<String> {
stored
.filter(|k| !k.trim().is_empty())
.or_else(|| env_key(api_key_env))
}
fn env_key(var: Option<&str>) -> Option<String> {
let var = var?.trim();
if var.is_empty() {
return None;
}
std::env::var(var).ok().filter(|v| !v.is_empty())
}
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
pub(crate) async fn error_body(what: &str, resp: reqwest::Response) -> anyhow::Error {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let detail: String = body.trim().chars().take(500).collect();
tracing::warn!(%status, body = %detail, "{what} returned an error status");
if detail.is_empty() {
anyhow::anyhow!("{what}: status {status}")
} else {
anyhow::anyhow!("{what}: status {status}: {detail}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::config::{SecretSlot, TtsCloudSettings, TtsExternalSettings};
#[test]
fn cloud_requires_model_and_key() {
let mut tts = TtsSettings {
openai: TtsCloudSettings {
model_name: None,
..Default::default()
},
..Default::default()
};
assert_eq!(
engines_from_config(&tts, Some("sk-x".into())).err(),
Some(TtsSetupError::Model)
);
tts.openai.model_name = Some("gpt-4o-mini-tts".into());
assert_eq!(
engines_from_config(&tts, None).err(),
Some(TtsSetupError::ApiKey)
);
assert!(engines_from_config(&tts, Some("sk-x".into())).is_ok());
}
#[test]
fn external_requires_url_only() {
let tts = TtsSettings {
mode: TtsMode::External,
external: TtsExternalSettings::default(),
..Default::default()
};
assert_eq!(
engines_from_config(&tts, None).err(),
Some(TtsSetupError::Url)
);
let tts = TtsSettings {
mode: TtsMode::External,
external: TtsExternalSettings {
url: Some("http://127.0.0.1:8880/v1".into()),
..Default::default()
},
..Default::default()
};
assert!(engines_from_config(&tts, None).is_ok());
}
#[test]
fn stored_key_wins_over_the_named_variable() {
let from_env = std::env::var("PATH").unwrap();
assert_eq!(
resolve_key(Some("sk-slot".into()), Some("PATH")).as_deref(),
Some("sk-slot")
);
assert_eq!(resolve_key(None, Some("PATH")), Some(from_env));
assert!(
resolve_key(Some(" ".into()), Some("PATH")).is_some(),
"a blank stored key falls through to the variable, not to nothing"
);
assert_eq!(resolve_key(Some(" ".into()), None), None);
assert_eq!(resolve_key(None, None), None);
assert_eq!(
resolve_key(None, Some("MINDFORK_DEFINITELY_UNSET_VAR_42")),
None
);
}
#[test]
fn external_takes_a_stored_key_but_does_not_require_one() {
let tts = TtsSettings {
mode: TtsMode::External,
external: TtsExternalSettings {
url: Some("http://127.0.0.1:8880/v1".into()),
..Default::default()
},
..Default::default()
};
assert_eq!(
tts.secret_key(),
Some(crate::shared::secrets::SecretKey::External(
crate::shared::secrets::ExternalSlot::Tts
)),
"the speech slot must not read a provider's key in external mode"
);
assert!(engines_from_config(&tts, Some("sk-voice".into())).is_ok());
assert!(engines_from_config(&tts, None).is_ok());
}
#[test]
fn blank_fields_count_as_unset() {
let tts = TtsSettings {
openai: TtsCloudSettings {
model_name: Some(" ".into()),
..Default::default()
},
..Default::default()
};
assert_eq!(
engines_from_config(&tts, Some("sk-x".into())).err(),
Some(TtsSetupError::Model)
);
}
#[test]
fn second_engine_built_only_when_user_voice_set_and_differs() {
let base = TtsSettings {
openai: TtsCloudSettings {
model_name: Some("gpt-4o-mini-tts".into()),
voice: Some("onyx".into()),
..Default::default()
},
..Default::default()
};
let (_a, user) = engines_from_config(&base, Some("sk-x".into())).unwrap();
assert!(user.is_none(), "no user_voice → no second engine is built");
let mut with_user = base.clone();
with_user.openai.user_voice = Some("nova".into());
let (_a, user) = engines_from_config(&with_user, Some("sk-x".into())).unwrap();
assert!(user.is_some(), "a separate user_voice → a second engine");
let mut same = base.clone();
same.openai.user_voice = Some("onyx".into());
let (_a, user) = engines_from_config(&same, Some("sk-x".into())).unwrap();
assert!(user.is_none(), "matching voice — a single engine");
}
#[test]
fn empty_clip_detected() {
assert!(AudioClip::Encoded(Vec::new()).is_empty());
assert!(
AudioClip::Pcm {
sample_rate: 24000,
channels: 1,
bytes: vec![0],
}
.is_empty()
);
assert!(!AudioClip::Encoded(vec![1, 2, 3]).is_empty());
}
}