Skip to main content

adk_audio/providers/tts/
elevenlabs.rs

1//! ElevenLabs TTS provider.
2
3use std::pin::Pin;
4
5use async_trait::async_trait;
6use futures::Stream;
7
8use crate::error::{AudioError, AudioResult};
9use crate::frame::AudioFrame;
10use crate::providers::tts::CloudTtsConfig;
11use crate::traits::{Emotion, TtsProvider, TtsRequest, Voice};
12
13/// ElevenLabs TTS provider.
14///
15/// Uses the ElevenLabs v1 API for high-quality voice synthesis.
16/// Configure via `ELEVENLABS_API_KEY` environment variable or builder.
17pub struct ElevenLabsTts {
18    config: CloudTtsConfig,
19    client: reqwest::Client,
20    voices: Vec<Voice>,
21}
22
23impl ElevenLabsTts {
24    /// Create from environment variable `ELEVENLABS_API_KEY`.
25    pub fn from_env() -> AudioResult<Self> {
26        let api_key = std::env::var("ELEVENLABS_API_KEY").map_err(|_| AudioError::Tts {
27            provider: "elevenlabs".into(),
28            message: "ELEVENLABS_API_KEY not set".into(),
29        })?;
30        Ok(Self::new(CloudTtsConfig::new(api_key)))
31    }
32
33    /// Create with explicit config.
34    pub fn new(config: CloudTtsConfig) -> Self {
35        Self {
36            config,
37            client: reqwest::Client::new(),
38            voices: vec![
39                Voice {
40                    id: "21m00Tcm4TlvDq8ikWAM".into(),
41                    name: "Rachel".into(),
42                    language: "en".into(),
43                    gender: Some("female".into()),
44                },
45                Voice {
46                    id: "AZnzlk1XvdvUeBnXmlld".into(),
47                    name: "Domi".into(),
48                    language: "en".into(),
49                    gender: Some("female".into()),
50                },
51                Voice {
52                    id: "EXAVITQu4vr4xnSDxMaL".into(),
53                    name: "Bella".into(),
54                    language: "en".into(),
55                    gender: Some("female".into()),
56                },
57                Voice {
58                    id: "ErXwobaYiN019PkySvjV".into(),
59                    name: "Antoni".into(),
60                    language: "en".into(),
61                    gender: Some("male".into()),
62                },
63            ],
64        }
65    }
66
67    fn base_url(&self) -> &str {
68        self.config.base_url.as_deref().unwrap_or("https://api.elevenlabs.io")
69    }
70
71    fn emotion_to_settings(&self, emotion: Option<&Emotion>) -> serde_json::Value {
72        match emotion {
73            Some(Emotion::Happy) => serde_json::json!({"stability": 0.4, "similarity_boost": 0.8}),
74            Some(Emotion::Sad) => serde_json::json!({"stability": 0.7, "similarity_boost": 0.6}),
75            Some(Emotion::Angry) => serde_json::json!({"stability": 0.3, "similarity_boost": 0.9}),
76            Some(Emotion::Whisper) => {
77                serde_json::json!({"stability": 0.9, "similarity_boost": 0.3})
78            }
79            Some(Emotion::Excited) => {
80                serde_json::json!({"stability": 0.3, "similarity_boost": 0.8})
81            }
82            Some(Emotion::Calm) => serde_json::json!({"stability": 0.8, "similarity_boost": 0.5}),
83            _ => serde_json::json!({"stability": 0.5, "similarity_boost": 0.75}),
84        }
85    }
86}
87
88#[async_trait]
89impl TtsProvider for ElevenLabsTts {
90    async fn synthesize(&self, request: &TtsRequest) -> AudioResult<AudioFrame> {
91        let voice_id = if request.voice.is_empty() { &self.voices[0].id } else { &request.voice };
92        let url =
93            format!("{}/v1/text-to-speech/{voice_id}?output_format=pcm_24000", self.base_url());
94        let voice_settings = self.emotion_to_settings(request.emotion.as_ref());
95
96        let body = serde_json::json!({
97            "text": request.text,
98            "model_id": "eleven_multilingual_v2",
99            "voice_settings": voice_settings,
100        });
101
102        let resp = self
103            .client
104            .post(&url)
105            .header("xi-api-key", &self.config.api_key)
106            .json(&body)
107            .send()
108            .await
109            .map_err(|e| AudioError::Tts {
110                provider: "elevenlabs".into(),
111                message: e.to_string(),
112            })?;
113
114        if !resp.status().is_success() {
115            return Err(AudioError::Tts {
116                provider: "elevenlabs".into(),
117                message: format!("HTTP {}", resp.status()),
118            });
119        }
120
121        let pcm = resp.bytes().await.map_err(|e| AudioError::Tts {
122            provider: "elevenlabs".into(),
123            message: e.to_string(),
124        })?;
125
126        Ok(AudioFrame::new(pcm, 24000, 1))
127    }
128
129    async fn synthesize_stream(
130        &self,
131        request: &TtsRequest,
132    ) -> AudioResult<Pin<Box<dyn Stream<Item = AudioResult<AudioFrame>> + Send>>> {
133        let voice_id = if request.voice.is_empty() {
134            self.voices[0].id.clone()
135        } else {
136            request.voice.clone()
137        };
138        let url = format!(
139            "{}/v1/text-to-speech/{voice_id}/stream?output_format=pcm_24000",
140            self.base_url()
141        );
142        let voice_settings = self.emotion_to_settings(request.emotion.as_ref());
143
144        let body = serde_json::json!({
145            "text": request.text,
146            "model_id": "eleven_multilingual_v2",
147            "voice_settings": voice_settings,
148        });
149
150        let resp = self
151            .client
152            .post(&url)
153            .header("xi-api-key", &self.config.api_key)
154            .json(&body)
155            .send()
156            .await
157            .map_err(|e| AudioError::Tts {
158                provider: "elevenlabs".into(),
159                message: e.to_string(),
160            })?;
161
162        if !resp.status().is_success() {
163            return Err(AudioError::Tts {
164                provider: "elevenlabs".into(),
165                message: format!("HTTP {}", resp.status()),
166            });
167        }
168
169        let stream = async_stream::stream! {
170            use futures::StreamExt;
171            let mut byte_stream = resp.bytes_stream();
172            while let Some(chunk) = byte_stream.next().await {
173                match chunk {
174                    Ok(data) => {
175                        if data.len() >= 2 {
176                            yield Ok(AudioFrame::new(data, 24000, 1));
177                        }
178                    }
179                    Err(e) => {
180                        yield Err(AudioError::Tts { provider: "elevenlabs".into(), message: e.to_string() });
181                    }
182                }
183            }
184        };
185
186        Ok(Box::pin(stream))
187    }
188
189    fn voice_catalog(&self) -> &[Voice] {
190        &self.voices
191    }
192}