1use crate::errors::{Result, TypecastError};
6use crate::models::{
7 Age, AudioFormat, ErrorResponse, Gender, TTSModel, TTSRequest, TTSResponse, UseCase, VoiceV2,
8 VoicesV2Filter,
9};
10use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
11use std::env;
12use std::time::Duration;
13
14fn model_query_value(model: TTSModel) -> &'static str {
16 match model {
17 TTSModel::SsfmV30 => "ssfm-v30",
18 TTSModel::SsfmV21 => "ssfm-v21",
19 }
20}
21
22fn gender_query_value(gender: Gender) -> &'static str {
24 match gender {
25 Gender::Male => "male",
26 Gender::Female => "female",
27 }
28}
29
30fn age_query_value(age: Age) -> &'static str {
32 match age {
33 Age::Child => "child",
34 Age::Teenager => "teenager",
35 Age::YoungAdult => "young_adult",
36 Age::MiddleAge => "middle_age",
37 Age::Elder => "elder",
38 }
39}
40
41fn use_case_query_value(use_case: UseCase) -> &'static str {
43 match use_case {
44 UseCase::Announcer => "Announcer",
45 UseCase::Anime => "Anime",
46 UseCase::Audiobook => "Audiobook",
47 UseCase::Conversational => "Conversational",
48 UseCase::Documentary => "Documentary",
49 UseCase::ELearning => "E-learning",
50 UseCase::Rapper => "Rapper",
51 UseCase::Game => "Game",
52 UseCase::TikTokReels => "Tiktok/Reels",
53 UseCase::News => "News",
54 UseCase::Podcast => "Podcast",
55 UseCase::Voicemail => "Voicemail",
56 UseCase::Ads => "Ads",
57 }
58}
59
60pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
62
63pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
65
66#[derive(Debug, Clone)]
68pub struct ClientConfig {
69 pub api_key: String,
71 pub base_url: String,
73 pub timeout: Duration,
75}
76
77impl Default for ClientConfig {
78 fn default() -> Self {
79 Self {
80 api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
81 base_url: env::var("TYPECAST_API_HOST").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
82 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
83 }
84 }
85}
86
87impl ClientConfig {
88 pub fn new(api_key: impl Into<String>) -> Self {
90 Self {
91 api_key: api_key.into(),
92 ..Default::default()
93 }
94 }
95
96 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
98 self.base_url = base_url.into();
99 self
100 }
101
102 pub fn timeout(mut self, timeout: Duration) -> Self {
104 self.timeout = timeout;
105 self
106 }
107}
108
109#[derive(Debug, Clone)]
111pub struct TypecastClient {
112 client: reqwest::Client,
113 base_url: String,
114 api_key: String,
115}
116
117impl TypecastClient {
118 pub fn new(config: ClientConfig) -> Result<Self> {
120 let mut headers = HeaderMap::new();
121 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
122 headers.insert(
123 "X-API-KEY",
124 HeaderValue::from_str(&config.api_key)
125 .map_err(|_| TypecastError::BadRequest {
126 detail: "Invalid API key format".to_string()
127 })?,
128 );
129
130 let client = reqwest::Client::builder()
133 .default_headers(headers)
134 .timeout(config.timeout)
135 .build()
136 .expect("reqwest client builder should not fail");
137
138 Ok(Self {
139 client,
140 base_url: config.base_url,
141 api_key: config.api_key,
142 })
143 }
144
145 pub fn from_env() -> Result<Self> {
149 Self::new(ClientConfig::default())
150 }
151
152 pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
154 Self::new(ClientConfig::new(api_key))
155 }
156
157 pub fn base_url(&self) -> &str {
159 &self.base_url
160 }
161
162 pub fn api_key_masked(&self) -> String {
164 if self.api_key.len() > 8 {
165 format!("{}...{}", &self.api_key[..4], &self.api_key[self.api_key.len()-4..])
166 } else {
167 "****".to_string()
168 }
169 }
170
171 fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
176 let base = format!("{}{}", self.base_url, path);
177 match params {
178 Some(params) => {
179 let query: Vec<String> = params
180 .into_iter()
181 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
182 .collect();
183 format!("{}?{}", base, query.join("&"))
184 }
185 None => base,
186 }
187 }
188
189 async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
191 let status_code = response.status().as_u16();
192 let error_response: Option<ErrorResponse> = response.json().await.ok();
193 TypecastError::from_response(status_code, error_response)
194 }
195
196 pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
224 let url = self.build_url("/v1/text-to-speech", None);
225
226 let response = self.client
227 .post(&url)
228 .json(request)
229 .send()
230 .await?;
231
232 if !response.status().is_success() {
233 return Err(self.handle_error_response(response).await);
234 }
235
236 let content_type = response
238 .headers()
239 .get(CONTENT_TYPE)
240 .and_then(|v| v.to_str().ok())
241 .unwrap_or("audio/wav");
242
243 let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
244 AudioFormat::Mp3
245 } else {
246 AudioFormat::Wav
247 };
248
249 let duration = response
251 .headers()
252 .get("X-Audio-Duration")
253 .and_then(|v| v.to_str().ok())
254 .and_then(|v| v.parse::<f64>().ok())
255 .unwrap_or(0.0);
256
257 let audio_data = response.bytes().await?.to_vec();
258
259 Ok(TTSResponse {
260 audio_data,
261 duration,
262 format,
263 })
264 }
265
266 pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
296 let mut params = Vec::new();
297
298 if let Some(f) = filter {
299 if let Some(model) = f.model {
300 params.push(("model", model_query_value(model).to_string()));
301 }
302 if let Some(gender) = f.gender {
303 params.push(("gender", gender_query_value(gender).to_string()));
304 }
305 if let Some(age) = f.age {
306 params.push(("age", age_query_value(age).to_string()));
307 }
308 if let Some(use_cases) = f.use_cases {
309 params.push(("use_cases", use_case_query_value(use_cases).to_string()));
310 }
311 }
312
313 let url = self.build_url("/v2/voices", if params.is_empty() { None } else { Some(params) });
314
315 let response = self.client
316 .get(&url)
317 .send()
318 .await?;
319
320 if !response.status().is_success() {
321 return Err(self.handle_error_response(response).await);
322 }
323
324 let voices: Vec<VoiceV2> = response.json().await?;
325 Ok(voices)
326 }
327
328 pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
351 let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
352
353 let response = self.client
354 .get(&url)
355 .send()
356 .await?;
357
358 if !response.status().is_success() {
359 return Err(self.handle_error_response(response).await);
360 }
361
362 let voice: VoiceV2 = response.json().await?;
363 Ok(voice)
364 }
365}
366
367mod urlencoding {
369 pub fn encode(s: &str) -> String {
370 url_encode(s)
371 }
372
373 fn url_encode(s: &str) -> String {
374 let mut result = String::new();
375 for c in s.chars() {
376 match c {
377 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
378 result.push(c);
379 }
380 _ => {
381 for b in c.to_string().as_bytes() {
382 result.push_str(&format!("%{:02X}", b));
383 }
384 }
385 }
386 }
387 result
388 }
389}