use async_trait::async_trait;
use regex::Regex;
use serde::Deserialize;
use serde_json::json;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use lingshu_types::{ToolError, ToolSchema};
use crate::registry::{ToolContext, ToolHandler};
const DEFAULT_EDGE_TTS_VOICE: &str = "en-US-AriaNeural";
const DEFAULT_OPENAI_VOICE: &str = "alloy";
const DEFAULT_ELEVENLABS_VOICE_ID: &str = "21m00Tcm4TlvDq8ikWAM";
fn elevenlabs_tts_available(api_key_env: &str) -> bool {
std::env::var(api_key_env)
.map(|k| !k.is_empty())
.unwrap_or(false)
}
fn edge_tts_available() -> bool {
std::process::Command::new("edge-tts")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn openai_tts_available() -> bool {
std::env::var("OPENAI_API_KEY")
.map(|k| !k.is_empty())
.unwrap_or(false)
}
enum TtsBackend {
ElevenLabs,
OpenAi,
EdgeTts,
None,
}
fn configured_elevenlabs_api_key_env(ctx: &ToolContext) -> &str {
ctx.config
.tts_elevenlabs_api_key_env
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or("ELEVENLABS_API_KEY")
}
fn detect_backend(ctx: &ToolContext) -> TtsBackend {
let elevenlabs_api_key_env = configured_elevenlabs_api_key_env(ctx);
if let Ok(pref) = std::env::var("EDGECRAB_TTS_PROVIDER") {
match pref.to_lowercase().as_str() {
"elevenlabs" | "eleven" if elevenlabs_tts_available(elevenlabs_api_key_env) => {
return TtsBackend::ElevenLabs;
}
"openai" if openai_tts_available() => return TtsBackend::OpenAi,
"edge-tts" | "edge" if edge_tts_available() => return TtsBackend::EdgeTts,
_ => {} }
}
if let Some(pref) = ctx.config.tts_provider.as_deref() {
match pref.to_ascii_lowercase().as_str() {
"elevenlabs" | "eleven" if elevenlabs_tts_available(elevenlabs_api_key_env) => {
return TtsBackend::ElevenLabs;
}
"openai" if openai_tts_available() => return TtsBackend::OpenAi,
"edge-tts" | "edge" if edge_tts_available() => return TtsBackend::EdgeTts,
_ => {}
}
}
if edge_tts_available() {
return TtsBackend::EdgeTts;
}
if elevenlabs_tts_available(elevenlabs_api_key_env) {
return TtsBackend::ElevenLabs;
}
if openai_tts_available() {
return TtsBackend::OpenAi;
}
TtsBackend::None
}
pub fn extract_audio_path_from_tts_output(output: &str) -> Option<String> {
if let Some(index) = output.find("MEDIA:") {
let path = output[index + "MEDIA:".len()..]
.lines()
.next()
.map(str::trim)
.filter(|path| !path.is_empty())?;
return Some(path.to_string());
}
output
.strip_prefix("Audio saved to: ")
.map(str::trim)
.filter(|path| !path.is_empty())
.map(str::to_string)
}
fn code_block_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(?s)```.*?```").expect("valid code block regex"))
}
fn markdown_link_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"\[([^\]]+)\]\([^)]+\)").expect("valid markdown link regex"))
}
fn url_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"https?://\S+").expect("valid url regex"))
}
fn inline_code_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"`([^`]+)`").expect("valid inline code regex"))
}
fn bold_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(\*\*|__)(.+?)(\*\*|__)").expect("valid bold regex"))
}
fn italic_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(\*|_)(.+?)(\*|_)").expect("valid italic regex"))
}
fn header_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(?m)^\s{0,3}#+\s*").expect("valid header regex"))
}
fn list_item_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(?m)^\s*[-*+]\s+").expect("valid list item regex"))
}
fn hr_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"(?m)^\s*[-*_]{3,}\s*$").expect("valid hr regex"))
}
fn media_tag_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r"(?m)^\s*(\[\[audio_as_voice\]\]|MEDIA:\S+)\s*$").expect("valid media regex")
})
}
fn blank_line_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"\n{3,}").expect("valid blank line regex"))
}
fn inline_space_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| Regex::new(r"[ \t]{2,}").expect("valid inline space regex"))
}
fn strip_markdown_for_tts(text: &str) -> String {
let text = code_block_regex().replace_all(text, " ");
let text = markdown_link_regex().replace_all(&text, "$1");
let text = url_regex().replace_all(&text, "");
let text = media_tag_regex().replace_all(&text, "");
let text = bold_regex().replace_all(&text, "$2");
let text = italic_regex().replace_all(&text, "$2");
let text = inline_code_regex().replace_all(&text, "$1");
let text = header_regex().replace_all(&text, "");
let text = list_item_regex().replace_all(&text, "");
let text = hr_regex().replace_all(&text, "");
let text = blank_line_regex().replace_all(&text, "\n\n");
inline_space_regex()
.replace_all(&text, " ")
.trim()
.to_string()
}
fn truncate_chars(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
text.chars().take(limit).collect()
}
pub fn sanitize_text_for_tts(text: &str, max_chars: usize) -> Option<String> {
let cleaned = strip_markdown_for_tts(text);
let cleaned = truncate_chars(&cleaned, max_chars);
if cleaned.trim().is_empty() || !cleaned.chars().any(char::is_alphanumeric) {
return None;
}
Some(cleaned)
}
async fn tts_edge(
text: &str,
voice: &str,
rate: Option<&str>,
output_path: &Path,
) -> Result<String, ToolError> {
let mut cmd = tokio::process::Command::new("edge-tts");
cmd.args([
"--text",
text,
"--voice",
voice,
"--write-media",
&output_path.to_string_lossy(),
]);
if let Some(rate) = rate.filter(|value| !value.trim().is_empty()) {
cmd.args(["--rate", rate]);
}
let output = cmd.output().await.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("Failed to run edge-tts: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("edge-tts failed: {stderr}"),
});
}
Ok(output_path.to_string_lossy().into_owned())
}
async fn tts_openai(
text: &str,
voice: &str,
model: Option<&str>,
output_path: &Path,
) -> Result<String, ToolError> {
let api_key = std::env::var("OPENAI_API_KEY").map_err(|_| ToolError::Unavailable {
tool: "text_to_speech".into(),
reason: "OPENAI_API_KEY not set".into(),
})?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("HTTP client error: {e}"),
})?;
let resp = client
.post("https://api.openai.com/v1/audio/speech")
.bearer_auth(&api_key)
.json(&json!({
"model": model.filter(|value| !value.trim().is_empty()).unwrap_or("tts-1"),
"input": text,
"voice": voice,
"response_format": "mp3"
}))
.send()
.await
.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("OpenAI TTS API error: {e}"),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("OpenAI TTS API returned {status}: {body}"),
});
}
let bytes = resp.bytes().await.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("Failed to read audio response: {e}"),
})?;
tokio::fs::write(output_path, &bytes)
.await
.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("Failed to write audio file: {e}"),
})?;
Ok(output_path.to_string_lossy().into_owned())
}
async fn tts_elevenlabs(
text: &str,
api_key_env: &str,
voice_id: &str,
model_id_override: Option<&str>,
output_path: &Path,
) -> Result<String, ToolError> {
let api_key = std::env::var(api_key_env).map_err(|_| ToolError::Unavailable {
tool: "text_to_speech".into(),
reason: format!("{api_key_env} not set"),
})?;
let model_id = model_id_override
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| std::env::var("ELEVENLABS_MODEL_ID").ok())
.unwrap_or_else(|| "eleven_turbo_v2".to_string());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("HTTP client error: {e}"),
})?;
let url = format!("https://api.elevenlabs.io/v1/text-to-speech/{voice_id}");
let resp = client
.post(&url)
.header("xi-api-key", &api_key)
.header("Content-Type", "application/json")
.header("Accept", "audio/mpeg")
.json(&json!({
"text": text,
"model_id": model_id,
"voice_settings": {
"stability": 0.5,
"similarity_boost": 0.75
}
}))
.send()
.await
.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("ElevenLabs TTS API error: {e}"),
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("ElevenLabs TTS API returned {status}: {body}"),
});
}
let bytes = resp.bytes().await.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("Failed to read audio response: {e}"),
})?;
tokio::fs::write(output_path, &bytes)
.await
.map_err(|e| ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("Failed to write audio file: {e}"),
})?;
Ok(output_path.to_string_lossy().into_owned())
}
pub struct TextToSpeechTool;
#[derive(Deserialize)]
struct TtsArgs {
text: String,
#[serde(default)]
voice: Option<String>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
rate: Option<String>,
#[serde(default)]
output_path: Option<String>,
}
#[async_trait]
impl ToolHandler for TextToSpeechTool {
fn name(&self) -> &'static str {
"text_to_speech"
}
fn toolset(&self) -> &'static str {
"media"
}
fn emoji(&self) -> &'static str {
"🔊"
}
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "text_to_speech".into(),
description: "Convert text to speech audio. Uses edge-tts (free), OpenAI TTS API, \
or ElevenLabs API. Returns the generated file path and a MEDIA: hint \
so the caller can deliver the audio natively."
.into(),
parameters: json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Text to convert to speech"
},
"voice": {
"type": "string",
"description": "Voice name (e.g. 'en-US-AriaNeural' for edge-tts, 'alloy' for OpenAI)"
},
"provider": {
"type": "string",
"description": "Optional backend override. One of: 'edge-tts', 'openai', 'elevenlabs'."
},
"model": {
"type": "string",
"description": "Optional provider-specific model override (for example 'tts-1-hd' or an ElevenLabs model id)."
},
"rate": {
"type": "string",
"description": "Optional speech rate override for edge-tts, such as '+10%' or '-5%'."
},
"output_path": {
"type": "string",
"description": "Output file path for the audio. Defaults to a temp file."
}
},
"required": ["text"]
}),
strict: None,
}
}
fn is_available(&self) -> bool {
edge_tts_available()
|| openai_tts_available()
|| elevenlabs_tts_available("ELEVENLABS_API_KEY")
}
async fn execute(
&self,
args: serde_json::Value,
ctx: &ToolContext,
) -> Result<String, ToolError> {
if ctx.cancel.is_cancelled() {
return Err(ToolError::Other("Cancelled".into()));
}
let args: TtsArgs = serde_json::from_value(args).map_err(|e| ToolError::InvalidArgs {
tool: "text_to_speech".into(),
message: e.to_string(),
})?;
if args.text.trim().is_empty() {
return Err(ToolError::InvalidArgs {
tool: "text_to_speech".into(),
message: "Text cannot be empty".into(),
});
}
let output_path = match args.output_path {
Some(ref p) => PathBuf::from(p),
None => {
let tmp_dir = std::env::temp_dir().join("lingshu_tts");
tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| {
ToolError::ExecutionFailed {
tool: "text_to_speech".into(),
message: format!("Failed to create temp dir: {e}"),
}
})?;
let id = uuid::Uuid::new_v4();
tmp_dir.join(format!("speech_{id}.mp3"))
}
};
if ctx.cancel.is_cancelled() {
return Err(ToolError::Other("Cancelled".into()));
}
let backend = if let Some(provider) = args.provider.as_deref() {
match provider.to_ascii_lowercase().as_str() {
"edge-tts" | "edge" if edge_tts_available() => TtsBackend::EdgeTts,
"openai" if openai_tts_available() => TtsBackend::OpenAi,
"elevenlabs" | "eleven"
if elevenlabs_tts_available(configured_elevenlabs_api_key_env(ctx)) =>
{
TtsBackend::ElevenLabs
}
"edge-tts" | "edge" | "openai" | "elevenlabs" | "eleven" => TtsBackend::None,
other => {
return Err(ToolError::InvalidArgs {
tool: "text_to_speech".into(),
message: format!(
"Unknown provider '{other}'. Use: edge-tts, openai, elevenlabs"
),
});
}
}
} else {
detect_backend(ctx)
};
let result = match backend {
TtsBackend::EdgeTts => {
let voice = args
.voice
.as_deref()
.or(ctx.config.tts_voice.as_deref())
.unwrap_or(DEFAULT_EDGE_TTS_VOICE);
let rate = args.rate.as_deref().or(ctx.config.tts_rate.as_deref());
tts_edge(&args.text, voice, rate, &output_path).await?
}
TtsBackend::ElevenLabs => {
let voice_id = args
.voice
.as_deref()
.or(ctx.config.tts_elevenlabs_voice_id.as_deref())
.or(ctx.config.tts_voice.as_deref())
.unwrap_or(DEFAULT_ELEVENLABS_VOICE_ID);
let model = args
.model
.as_deref()
.or(ctx.config.tts_elevenlabs_model_id.as_deref());
tts_elevenlabs(
&args.text,
configured_elevenlabs_api_key_env(ctx),
voice_id,
model,
&output_path,
)
.await?
}
TtsBackend::OpenAi => {
let voice = args
.voice
.as_deref()
.or(ctx.config.tts_voice.as_deref())
.unwrap_or(DEFAULT_OPENAI_VOICE);
let model = args.model.as_deref().or(ctx.config.tts_model.as_deref());
tts_openai(&args.text, voice, model, &output_path).await?
}
TtsBackend::None => {
return Err(ToolError::Unavailable {
tool: "text_to_speech".into(),
reason: "No TTS backend available. Install edge-tts (pip install edge-tts), \
set OPENAI_API_KEY, or configure ElevenLabs credentials."
.into(),
});
}
};
Ok(format!(
"Audio saved to: {result}\nUse MEDIA:{result} to send it natively."
))
}
}
inventory::submit!(&TextToSpeechTool as &dyn ToolHandler);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tts_schema_valid() {
let schema = TextToSpeechTool.schema();
assert_eq!(schema.name, "text_to_speech");
let required = schema.parameters["required"].as_array().expect("array");
assert!(required.iter().any(|v| v == "text"));
}
#[test]
fn tts_toolset() {
assert_eq!(TextToSpeechTool.toolset(), "media");
}
#[test]
fn tts_emoji() {
assert_eq!(TextToSpeechTool.emoji(), "🔊");
}
#[test]
fn detect_backend_returns_something() {
let ctx = ToolContext::test_context();
let _backend = detect_backend(&ctx);
}
#[tokio::test]
async fn tts_rejects_empty_text() {
let ctx = ToolContext::test_context();
let result = TextToSpeechTool.execute(json!({"text": " "}), &ctx).await;
assert!(result.is_err());
let err = result.expect_err("empty text");
assert!(err.to_string().contains("empty"));
}
#[tokio::test]
async fn tts_rejects_missing_text() {
let ctx = ToolContext::test_context();
let result = TextToSpeechTool.execute(json!({}), &ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn tts_cancelled() {
let ctx = ToolContext::test_context();
ctx.cancel.cancel();
let result = TextToSpeechTool
.execute(json!({"text": "hello"}), &ctx)
.await;
assert!(result.is_err());
assert!(
result
.expect_err("cancelled")
.to_string()
.contains("Cancelled")
);
}
#[test]
fn default_voices_are_not_empty() {
assert!(!DEFAULT_EDGE_TTS_VOICE.is_empty());
assert!(!DEFAULT_OPENAI_VOICE.is_empty());
}
#[test]
fn sanitize_text_for_tts_strips_markdown_and_links() {
assert_eq!(
sanitize_text_for_tts("## Title\n**bold** [docs](https://example.com)", 4000),
Some("Title\nbold docs".into())
);
}
#[test]
fn sanitize_text_for_tts_skips_markup_only_payloads() {
assert_eq!(
sanitize_text_for_tts("```rust\nfn main() {}\n```\nMEDIA:/tmp/reply.mp3", 4000),
None
);
}
#[test]
fn sanitize_text_for_tts_truncates_after_cleanup() {
let input = format!("**{}**", "a".repeat(5000));
assert_eq!(
sanitize_text_for_tts(&input, 4000)
.expect("sanitized text")
.chars()
.count(),
4000
);
}
#[test]
fn extract_audio_path_supports_media_and_legacy_output() {
assert_eq!(
extract_audio_path_from_tts_output("Generated audio.\nMEDIA:/tmp/reply.mp3"),
Some("/tmp/reply.mp3".into())
);
assert_eq!(
extract_audio_path_from_tts_output("Audio saved to: /tmp/reply.mp3"),
Some("/tmp/reply.mp3".into())
);
}
}