Skip to main content

typecast_rust/
client.rs

1//! Typecast API client
2//!
3//! This module contains the main client for interacting with the Typecast API.
4
5use 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
14/// Convert a [`TTSModel`] into the wire format string used in query parameters.
15fn model_query_value(model: TTSModel) -> &'static str {
16    match model {
17        TTSModel::SsfmV30 => "ssfm-v30",
18        TTSModel::SsfmV21 => "ssfm-v21",
19    }
20}
21
22/// Convert a [`Gender`] into the wire format string used in query parameters.
23fn gender_query_value(gender: Gender) -> &'static str {
24    match gender {
25        Gender::Male => "male",
26        Gender::Female => "female",
27    }
28}
29
30/// Convert an [`Age`] into the wire format string used in query parameters.
31fn 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
41/// Convert a [`UseCase`] into the wire format string used in query parameters.
42fn 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
60/// Default API base URL
61pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
62
63/// Default request timeout in seconds
64pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
65
66/// Configuration for the Typecast client
67#[derive(Debug, Clone)]
68pub struct ClientConfig {
69    /// API key for authentication
70    pub api_key: String,
71    /// Base URL for the API (defaults to <https://api.typecast.ai>)
72    pub base_url: String,
73    /// Request timeout duration
74    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    /// Create a new configuration with an API key
89    pub fn new(api_key: impl Into<String>) -> Self {
90        Self {
91            api_key: api_key.into(),
92            ..Default::default()
93        }
94    }
95
96    /// Set a custom base URL
97    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
98        self.base_url = base_url.into();
99        self
100    }
101
102    /// Set a custom timeout
103    pub fn timeout(mut self, timeout: Duration) -> Self {
104        self.timeout = timeout;
105        self
106    }
107}
108
109/// The main Typecast API client
110#[derive(Debug, Clone)]
111pub struct TypecastClient {
112    client: reqwest::Client,
113    base_url: String,
114    api_key: String,
115}
116
117impl TypecastClient {
118    /// Create a new TypecastClient with the given configuration
119    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        // `reqwest::Client::builder().build()` only fails if TLS init fails,
131        // which is not something we can usefully recover from at this layer.
132        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    /// Create a new TypecastClient from environment variables
146    ///
147    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
148    pub fn from_env() -> Result<Self> {
149        Self::new(ClientConfig::default())
150    }
151
152    /// Create a new TypecastClient with just an API key
153    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
154        Self::new(ClientConfig::new(api_key))
155    }
156
157    /// Get the base URL
158    pub fn base_url(&self) -> &str {
159        &self.base_url
160    }
161
162    /// Get the API key (masked)
163    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    /// Build a URL with optional query parameters.
172    ///
173    /// Callers must pass `None` when there are no query parameters; passing
174    /// `Some(vec![])` is not supported and will produce a trailing `?`.
175    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    /// Handle an error response
190    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    /// Convert text to speech
197    ///
198    /// # Arguments
199    ///
200    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
201    ///
202    /// # Returns
203    ///
204    /// Returns a `TTSResponse` containing the audio data, duration, and format
205    ///
206    /// # Example
207    ///
208    /// ```no_run
209    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
210    ///
211    /// # async fn example() -> typecast_rust::Result<()> {
212    /// let client = TypecastClient::from_env()?;
213    /// let request = TTSRequest::new(
214    ///     "tc_60e5426de8b95f1d3000d7b5",
215    ///     "Hello, world!",
216    ///     TTSModel::SsfmV30,
217    /// );
218    /// let response = client.text_to_speech(&request).await?;
219    /// println!("Audio duration: {} seconds", response.duration);
220    /// # Ok(())
221    /// # }
222    /// ```
223    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        // Parse content type for format
237        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        // Parse duration from header
250        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    /// Get voices with enhanced metadata (V2 API)
267    ///
268    /// # Arguments
269    ///
270    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
271    ///
272    /// # Returns
273    ///
274    /// Returns a list of `VoiceV2` with enhanced metadata
275    ///
276    /// # Example
277    ///
278    /// ```no_run
279    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
280    ///
281    /// # async fn example() -> typecast_rust::Result<()> {
282    /// let client = TypecastClient::from_env()?;
283    /// 
284    /// // Get all voices
285    /// let voices = client.get_voices_v2(None).await?;
286    /// 
287    /// // Get filtered voices
288    /// let filter = VoicesV2Filter::new()
289    ///     .model(TTSModel::SsfmV30)
290    ///     .gender(Gender::Female);
291    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
292    /// # Ok(())
293    /// # }
294    /// ```
295    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    /// Get a specific voice by ID with enhanced metadata (V2 API)
329    ///
330    /// # Arguments
331    ///
332    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
333    ///
334    /// # Returns
335    ///
336    /// Returns a `VoiceV2` with enhanced metadata
337    ///
338    /// # Example
339    ///
340    /// ```no_run
341    /// use typecast_rust::{TypecastClient, ClientConfig};
342    ///
343    /// # async fn example() -> typecast_rust::Result<()> {
344    /// let client = TypecastClient::from_env()?;
345    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
346    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
347    /// # Ok(())
348    /// # }
349    /// ```
350    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
367/// URL encoding helper
368mod 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}