pub mod deepgram_aura;
pub mod groq;
pub mod openai_compat;
use async_trait::async_trait;
use crate::{TtsConfig, WhisrsError};
const DEFAULT_SIDECAR_URL: &str = "http://127.0.0.1:8880/v1/audio/speech";
const OPENAI_SPEECH_URL: &str = "https://api.openai.com/v1/audio/speech";
const GROQ_DEFAULT_MODEL: &str = "canopylabs/orpheus-v1-english";
const GROQ_DEFAULT_VOICE: &str = "autumn";
const OPENAI_DEFAULT_MODEL: &str = "gpt-4o-mini-tts";
const OPENAI_DEFAULT_VOICE: &str = "alloy";
const SIDECAR_DEFAULT_MODEL: &str = "kokoro";
const SIDECAR_DEFAULT_VOICE: &str = "af_heart";
#[async_trait]
pub trait TtsBackend: Send + Sync {
async fn synthesize(&self, text: &str) -> Result<Vec<u8>, WhisrsError>;
}
pub fn create_backend(
config: &TtsConfig,
api_key: Option<String>,
) -> Result<Box<dyn TtsBackend>, WhisrsError> {
let api_key = api_key.filter(|k| !k.is_empty());
let require_key = |key: Option<String>| -> Result<String, WhisrsError> {
key.ok_or_else(|| {
WhisrsError::Config(
"TTS is enabled but no API key is configured.\n\
Add an api_key to [tts], or configure the backend's key \
(e.g. [groq] api_key / WHISRS_GROQ_API_KEY)."
.to_string(),
)
})
};
let model_or = |default: &str| -> String {
config
.model
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(default)
.to_string()
};
let voice_or = |default: &str| -> String {
config
.voice
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(default)
.to_string()
};
match config.backend.as_str() {
"groq" => Ok(Box::new(openai_compat::OpenAiCompatTts::new(
groq::GROQ_SPEECH_URL.to_string(),
Some(require_key(api_key)?),
model_or(GROQ_DEFAULT_MODEL),
voice_or(GROQ_DEFAULT_VOICE),
config.response_format.clone(),
))),
"openai" => Ok(Box::new(openai_compat::OpenAiCompatTts::new(
OPENAI_SPEECH_URL.to_string(),
Some(require_key(api_key)?),
model_or(OPENAI_DEFAULT_MODEL),
voice_or(OPENAI_DEFAULT_VOICE),
config.response_format.clone(),
))),
"tts-sidecar" | "openai-compat" => {
let base_url = config
.url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(DEFAULT_SIDECAR_URL)
.to_string();
Ok(Box::new(openai_compat::OpenAiCompatTts::new(
base_url,
api_key, model_or(SIDECAR_DEFAULT_MODEL),
voice_or(SIDECAR_DEFAULT_VOICE),
config.response_format.clone(),
)))
}
"deepgram" => Ok(Box::new(deepgram_aura::DeepgramAuraTts::new(
require_key(api_key)?,
model_or(deepgram_aura::DEFAULT_MODEL),
))),
other => Err(WhisrsError::Config(format!(
"Unknown TTS backend '{other}'. Valid options: groq, openai, tts-sidecar, deepgram"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(backend: &str) -> TtsConfig {
TtsConfig {
enabled: true,
backend: backend.to_string(),
..Default::default()
}
}
#[test]
fn create_groq_backend_requires_key() {
assert!(create_backend(&cfg("groq"), Some("k".to_string())).is_ok());
assert!(create_backend(&cfg("groq"), None).is_err());
}
#[test]
fn create_openai_backend_requires_key() {
assert!(create_backend(&cfg("openai"), Some("k".to_string())).is_ok());
assert!(create_backend(&cfg("openai"), None).is_err());
}
#[test]
fn create_deepgram_backend_requires_key() {
assert!(create_backend(&cfg("deepgram"), Some("k".to_string())).is_ok());
assert!(create_backend(&cfg("deepgram"), None).is_err());
}
#[test]
fn create_sidecar_backend_allows_no_key() {
assert!(create_backend(&cfg("tts-sidecar"), None).is_ok());
assert!(create_backend(&cfg("openai-compat"), None).is_ok());
}
#[test]
fn create_unknown_backend_errors() {
match create_backend(&cfg("bogus"), Some("k".to_string())) {
Err(e) => assert!(e.to_string().contains("Unknown TTS backend")),
Ok(_) => panic!("expected an error for an unknown backend"),
}
}
}