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    /// Convert text to speech with word- and/or character-level timestamps.
427    ///
428    /// # Arguments
429    ///
430    /// * `request` - The TTS request with timestamps parameters.
431    /// * `granularity` - Optional granularity: `None` (both), `"word"`, or `"char"`.
432    ///
433    /// # Returns
434    ///
435    /// A [`crate::timestamps::TTSWithTimestampsResponse`] containing the Base64-encoded audio
436    /// and alignment segment arrays.  Use the response's `.to_srt()` / `.to_vtt()` methods to
437    /// generate subtitle files.
438    ///
439    /// # Example
440    ///
441    /// ```no_run
442    /// use typecast_rust::{TypecastClient, TTSModel};
443    /// use typecast_rust::timestamps::TTSRequestWithTimestamps;
444    ///
445    /// # async fn example() -> typecast_rust::Result<()> {
446    /// let client = TypecastClient::from_env()?;
447    /// let request = TTSRequestWithTimestamps::new(
448    ///     "tc_60e5426de8b95f1d3000d7b5",
449    ///     "Hello, world!",
450    ///     TTSModel::SsfmV30,
451    /// );
452    /// let response = client.text_to_speech_with_timestamps(&request, None).await?;
453    /// let srt = response.to_srt()?;
454    /// println!("{}", srt);
455    /// # Ok(())
456    /// # }
457    /// ```
458    pub async fn text_to_speech_with_timestamps(
459        &self,
460        request: &crate::timestamps::TTSRequestWithTimestamps,
461        granularity: Option<&str>,
462    ) -> Result<crate::timestamps::TTSWithTimestampsResponse> {
463        if let Some(g) = granularity {
464            if g != "word" && g != "char" {
465                return Err(TypecastError::ValidationError {
466                    detail: format!(
467                        "granularity must be None, \"word\", or \"char\"; got {:?}",
468                        g
469                    ),
470                });
471            }
472        }
473
474        let url = match granularity {
475            Some(g) => self.build_url(
476                "/v1/text-to-speech/with-timestamps",
477                Some(vec![("granularity", g.to_string())]),
478            ),
479            None => self.build_url("/v1/text-to-speech/with-timestamps", None),
480        };
481
482        let response = self.client.post(&url).json(request).send().await?;
483
484        if !response.status().is_success() {
485            return Err(self.handle_error_response(response).await);
486        }
487
488        let parsed: crate::timestamps::TTSWithTimestampsResponse =
489            response.json().await.map_err(|e| TypecastError::DecodeError(e.to_string()))?;
490        Ok(parsed)
491    }
492
493    /// Get the authenticated user's subscription
494    ///
495    /// # Returns
496    ///
497    /// Returns a `SubscriptionResponse` containing the user's plan, credits,
498    /// and usage limits.
499    ///
500    /// # Example
501    ///
502    /// ```no_run
503    /// use typecast_rust::TypecastClient;
504    ///
505    /// # async fn example() -> typecast_rust::Result<()> {
506    /// let client = TypecastClient::from_env()?;
507    /// let subscription = client.get_my_subscription().await?;
508    /// println!("Plan: {:?}", subscription.plan);
509    /// println!(
510    ///     "Credits: {}/{}",
511    ///     subscription.credits.used_credits, subscription.credits.plan_credits
512    /// );
513    /// # Ok(())
514    /// # }
515    /// ```
516    pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
517        let url = self.build_url("/v1/users/me/subscription", None);
518
519        let response = self.client
520            .get(&url)
521            .send()
522            .await?;
523
524        if !response.status().is_success() {
525            return Err(self.handle_error_response(response).await);
526        }
527
528        let subscription: SubscriptionResponse = response.json().await?;
529        Ok(subscription)
530    }
531}
532
533/// URL encoding helper
534mod urlencoding {
535    pub fn encode(s: &str) -> String {
536        url_encode(s)
537    }
538
539    fn url_encode(s: &str) -> String {
540        let mut result = String::new();
541        for c in s.chars() {
542            match c {
543                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
544                    result.push(c);
545                }
546                _ => {
547                    for b in c.to_string().as_bytes() {
548                        result.push_str(&format!("%{:02X}", b));
549                    }
550                }
551            }
552        }
553        result
554    }
555}