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, SubscriptionResponse, TTSModel, TTSRequest,
8    TTSRequestStream, TTSResponse, UseCase, VoiceV2, VoicesV2Filter,
9};
10use bytes::Bytes;
11use futures_util::stream::{Stream, StreamExt};
12use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
13use std::env;
14use std::pin::Pin;
15use std::time::Duration;
16
17/// Boxed asynchronous stream of audio chunks returned by the streaming TTS endpoint.
18pub type AudioByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
19
20/// Convert a [`TTSModel`] into the wire format string used in query parameters.
21fn model_query_value(model: TTSModel) -> &'static str {
22    match model {
23        TTSModel::SsfmV30 => "ssfm-v30",
24        TTSModel::SsfmV21 => "ssfm-v21",
25    }
26}
27
28/// Convert a [`Gender`] into the wire format string used in query parameters.
29fn gender_query_value(gender: Gender) -> &'static str {
30    match gender {
31        Gender::Male => "male",
32        Gender::Female => "female",
33    }
34}
35
36/// Convert an [`Age`] into the wire format string used in query parameters.
37fn age_query_value(age: Age) -> &'static str {
38    match age {
39        Age::Child => "child",
40        Age::Teenager => "teenager",
41        Age::YoungAdult => "young_adult",
42        Age::MiddleAge => "middle_age",
43        Age::Elder => "elder",
44    }
45}
46
47/// Convert a [`UseCase`] into the wire format string used in query parameters.
48fn use_case_query_value(use_case: UseCase) -> &'static str {
49    match use_case {
50        UseCase::Announcer => "Announcer",
51        UseCase::Anime => "Anime",
52        UseCase::Audiobook => "Audiobook",
53        UseCase::Conversational => "Conversational",
54        UseCase::Documentary => "Documentary",
55        UseCase::ELearning => "E-learning",
56        UseCase::Rapper => "Rapper",
57        UseCase::Game => "Game",
58        UseCase::TikTokReels => "Tiktok/Reels",
59        UseCase::News => "News",
60        UseCase::Podcast => "Podcast",
61        UseCase::Voicemail => "Voicemail",
62        UseCase::Ads => "Ads",
63    }
64}
65
66/// Default API base URL
67pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
68
69/// Default request timeout in seconds
70pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
71
72/// Configuration for the Typecast client
73#[derive(Debug, Clone)]
74pub struct ClientConfig {
75    /// API key for authentication
76    pub api_key: String,
77    /// Base URL for the API (defaults to <https://api.typecast.ai>)
78    pub base_url: String,
79    /// Request timeout duration
80    pub timeout: Duration,
81}
82
83impl Default for ClientConfig {
84    fn default() -> Self {
85        Self {
86            api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
87            base_url: env::var("TYPECAST_API_HOST").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
88            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
89        }
90    }
91}
92
93impl ClientConfig {
94    /// Create a new configuration with an API key
95    pub fn new(api_key: impl Into<String>) -> Self {
96        Self {
97            api_key: api_key.into(),
98            ..Default::default()
99        }
100    }
101
102    /// Set a custom base URL
103    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
104        self.base_url = base_url.into();
105        self
106    }
107
108    /// Set a custom timeout
109    pub fn timeout(mut self, timeout: Duration) -> Self {
110        self.timeout = timeout;
111        self
112    }
113}
114
115/// The main Typecast API client
116#[derive(Debug, Clone)]
117pub struct TypecastClient {
118    client: reqwest::Client,
119    base_url: String,
120    api_key: String,
121}
122
123impl TypecastClient {
124    /// Create a new TypecastClient with the given configuration
125    pub fn new(config: ClientConfig) -> Result<Self> {
126        let mut headers = HeaderMap::new();
127        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
128        headers.insert(
129            "X-API-KEY",
130            HeaderValue::from_str(&config.api_key)
131                .map_err(|_| TypecastError::BadRequest { 
132                    detail: "Invalid API key format".to_string() 
133                })?,
134        );
135
136        // `reqwest::Client::builder().build()` only fails if TLS init fails,
137        // which is not something we can usefully recover from at this layer.
138        let client = reqwest::Client::builder()
139            .default_headers(headers)
140            .timeout(config.timeout)
141            .build()
142            .expect("reqwest client builder should not fail");
143
144        Ok(Self {
145            client,
146            base_url: config.base_url,
147            api_key: config.api_key,
148        })
149    }
150
151    /// Create a new TypecastClient from environment variables
152    ///
153    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
154    pub fn from_env() -> Result<Self> {
155        Self::new(ClientConfig::default())
156    }
157
158    /// Create a new TypecastClient with just an API key
159    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
160        Self::new(ClientConfig::new(api_key))
161    }
162
163    /// Get the base URL
164    pub fn base_url(&self) -> &str {
165        &self.base_url
166    }
167
168    /// Get the API key (masked)
169    pub fn api_key_masked(&self) -> String {
170        if self.api_key.len() > 8 {
171            format!("{}...{}", &self.api_key[..4], &self.api_key[self.api_key.len()-4..])
172        } else {
173            "****".to_string()
174        }
175    }
176
177    /// Build a URL with optional query parameters.
178    ///
179    /// Callers must pass `None` when there are no query parameters; passing
180    /// `Some(vec![])` is not supported and will produce a trailing `?`.
181    fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
182        let base = format!("{}{}", self.base_url, path);
183        match params {
184            Some(params) => {
185                let query: Vec<String> = params
186                    .into_iter()
187                    .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
188                    .collect();
189                format!("{}?{}", base, query.join("&"))
190            }
191            None => base,
192        }
193    }
194
195    /// Handle an error response
196    async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
197        let status_code = response.status().as_u16();
198        let error_response: Option<ErrorResponse> = response.json().await.ok();
199        TypecastError::from_response(status_code, error_response)
200    }
201
202    /// Convert text to speech
203    ///
204    /// # Arguments
205    ///
206    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
207    ///
208    /// # Returns
209    ///
210    /// Returns a `TTSResponse` containing the audio data, duration, and format
211    ///
212    /// # Example
213    ///
214    /// ```no_run
215    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
216    ///
217    /// # async fn example() -> typecast_rust::Result<()> {
218    /// let client = TypecastClient::from_env()?;
219    /// let request = TTSRequest::new(
220    ///     "tc_60e5426de8b95f1d3000d7b5",
221    ///     "Hello, world!",
222    ///     TTSModel::SsfmV30,
223    /// );
224    /// let response = client.text_to_speech(&request).await?;
225    /// println!("Audio duration: {} seconds", response.duration);
226    /// # Ok(())
227    /// # }
228    /// ```
229    pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
230        let url = self.build_url("/v1/text-to-speech", None);
231        
232        let response = self.client
233            .post(&url)
234            .json(request)
235            .send()
236            .await?;
237
238        if !response.status().is_success() {
239            return Err(self.handle_error_response(response).await);
240        }
241
242        // Parse content type for format
243        let content_type = response
244            .headers()
245            .get(CONTENT_TYPE)
246            .and_then(|v| v.to_str().ok())
247            .unwrap_or("audio/wav");
248        
249        let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
250            AudioFormat::Mp3
251        } else {
252            AudioFormat::Wav
253        };
254
255        // Parse duration from header
256        let duration = response
257            .headers()
258            .get("X-Audio-Duration")
259            .and_then(|v| v.to_str().ok())
260            .and_then(|v| v.parse::<f64>().ok())
261            .unwrap_or(0.0);
262
263        let audio_data = response.bytes().await?.to_vec();
264
265        Ok(TTSResponse {
266            audio_data,
267            duration,
268            format,
269        })
270    }
271
272    /// Convert text to speech as a streaming response
273    ///
274    /// Returns a stream of audio byte chunks. For `wav` output the first chunk
275    /// contains the WAV header followed by PCM samples; for `mp3` output each
276    /// chunk is independently decodable.
277    ///
278    /// # Arguments
279    ///
280    /// * `request` - The streaming TTS request
281    ///
282    /// # Returns
283    ///
284    /// A pinned boxed [`Stream`] yielding [`Result<Bytes>`] chunks.
285    ///
286    /// # Example
287    ///
288    /// ```no_run
289    /// use futures_util::StreamExt;
290    /// use typecast_rust::{TypecastClient, TTSRequestStream, TTSModel};
291    ///
292    /// # async fn example() -> typecast_rust::Result<()> {
293    /// let client = TypecastClient::from_env()?;
294    /// let request = TTSRequestStream::new(
295    ///     "tc_60e5426de8b95f1d3000d7b5",
296    ///     "Hello, world!",
297    ///     TTSModel::SsfmV30,
298    /// );
299    /// let mut stream = client.text_to_speech_stream(&request).await?;
300    /// while let Some(chunk) = stream.next().await {
301    ///     let bytes = chunk?;
302    ///     // write bytes to file or audio sink
303    ///     let _ = bytes;
304    /// }
305    /// # Ok(())
306    /// # }
307    /// ```
308    pub async fn text_to_speech_stream(
309        &self,
310        request: &TTSRequestStream,
311    ) -> Result<AudioByteStream> {
312        let url = self.build_url("/v1/text-to-speech/stream", None);
313
314        let response = self.client.post(&url).json(request).send().await?;
315
316        if !response.status().is_success() {
317            return Err(self.handle_error_response(response).await);
318        }
319
320        let stream = response
321            .bytes_stream()
322            .map(|item| item.map_err(TypecastError::from));
323        Ok(Box::pin(stream))
324    }
325
326    /// Get voices with enhanced metadata (V2 API)
327    ///
328    /// # Arguments
329    ///
330    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
331    ///
332    /// # Returns
333    ///
334    /// Returns a list of `VoiceV2` with enhanced metadata
335    ///
336    /// # Example
337    ///
338    /// ```no_run
339    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
340    ///
341    /// # async fn example() -> typecast_rust::Result<()> {
342    /// let client = TypecastClient::from_env()?;
343    /// 
344    /// // Get all voices
345    /// let voices = client.get_voices_v2(None).await?;
346    /// 
347    /// // Get filtered voices
348    /// let filter = VoicesV2Filter::new()
349    ///     .model(TTSModel::SsfmV30)
350    ///     .gender(Gender::Female);
351    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
356        let mut params = Vec::new();
357
358        if let Some(f) = filter {
359            if let Some(model) = f.model {
360                params.push(("model", model_query_value(model).to_string()));
361            }
362            if let Some(gender) = f.gender {
363                params.push(("gender", gender_query_value(gender).to_string()));
364            }
365            if let Some(age) = f.age {
366                params.push(("age", age_query_value(age).to_string()));
367            }
368            if let Some(use_cases) = f.use_cases {
369                params.push(("use_cases", use_case_query_value(use_cases).to_string()));
370            }
371        }
372
373        let url = self.build_url("/v2/voices", if params.is_empty() { None } else { Some(params) });
374
375        let response = self.client
376            .get(&url)
377            .send()
378            .await?;
379
380        if !response.status().is_success() {
381            return Err(self.handle_error_response(response).await);
382        }
383
384        let voices: Vec<VoiceV2> = response.json().await?;
385        Ok(voices)
386    }
387
388    /// Get a specific voice by ID with enhanced metadata (V2 API)
389    ///
390    /// # Arguments
391    ///
392    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
393    ///
394    /// # Returns
395    ///
396    /// Returns a `VoiceV2` with enhanced metadata
397    ///
398    /// # Example
399    ///
400    /// ```no_run
401    /// use typecast_rust::{TypecastClient, ClientConfig};
402    ///
403    /// # async fn example() -> typecast_rust::Result<()> {
404    /// let client = TypecastClient::from_env()?;
405    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
406    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
407    /// # Ok(())
408    /// # }
409    /// ```
410    pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
411        let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
412
413        let response = self.client
414            .get(&url)
415            .send()
416            .await?;
417
418        if !response.status().is_success() {
419            return Err(self.handle_error_response(response).await);
420        }
421
422        let voice: VoiceV2 = response.json().await?;
423        Ok(voice)
424    }
425
426    /// Get the authenticated user's subscription
427    ///
428    /// # Returns
429    ///
430    /// Returns a `SubscriptionResponse` containing the user's plan, credits,
431    /// and usage limits.
432    ///
433    /// # Example
434    ///
435    /// ```no_run
436    /// use typecast_rust::TypecastClient;
437    ///
438    /// # async fn example() -> typecast_rust::Result<()> {
439    /// let client = TypecastClient::from_env()?;
440    /// let subscription = client.get_my_subscription().await?;
441    /// println!("Plan: {:?}", subscription.plan);
442    /// println!(
443    ///     "Credits: {}/{}",
444    ///     subscription.credits.used_credits, subscription.credits.plan_credits
445    /// );
446    /// # Ok(())
447    /// # }
448    /// ```
449    pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
450        let url = self.build_url("/v1/users/me/subscription", None);
451
452        let response = self.client
453            .get(&url)
454            .send()
455            .await?;
456
457        if !response.status().is_success() {
458            return Err(self.handle_error_response(response).await);
459        }
460
461        let subscription: SubscriptionResponse = response.json().await?;
462        Ok(subscription)
463    }
464}
465
466/// URL encoding helper
467mod urlencoding {
468    pub fn encode(s: &str) -> String {
469        url_encode(s)
470    }
471
472    fn url_encode(s: &str) -> String {
473        let mut result = String::new();
474        for c in s.chars() {
475            match c {
476                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
477                    result.push(c);
478                }
479                _ => {
480                    for b in c.to_string().as_bytes() {
481                        result.push_str(&format!("%{:02X}", b));
482                    }
483                }
484            }
485        }
486        result
487    }
488}