adk_audio/providers/tts/
elevenlabs.rs1use 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
13pub struct ElevenLabsTts {
18 config: CloudTtsConfig,
19 client: reqwest::Client,
20 voices: Vec<Voice>,
21}
22
23impl ElevenLabsTts {
24 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 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}