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, CustomVoice, ErrorResponse, Gender, SubscriptionResponse, TTSModel,
8    TTSRequest, TTSRequestStream, TTSResponse, UseCase, VoiceV2, VoicesV2Filter,
9    CLONING_MAX_FILE_SIZE, NAME_MAX_LENGTH, NAME_MIN_LENGTH,
10};
11use bytes::Bytes;
12use futures_util::stream::{Stream, StreamExt};
13use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
14use std::env;
15use std::pin::Pin;
16use std::time::Duration;
17
18/// Boxed asynchronous stream of audio chunks returned by the streaming TTS endpoint.
19pub type AudioByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
20
21/// Convert a [`TTSModel`] into the wire format string used in query parameters.
22fn model_query_value(model: TTSModel) -> &'static str {
23    match model {
24        TTSModel::SsfmV30 => "ssfm-v30",
25        TTSModel::SsfmV21 => "ssfm-v21",
26    }
27}
28
29/// Convert a [`Gender`] into the wire format string used in query parameters.
30fn gender_query_value(gender: Gender) -> &'static str {
31    match gender {
32        Gender::Male => "male",
33        Gender::Female => "female",
34    }
35}
36
37/// Convert an [`Age`] into the wire format string used in query parameters.
38fn age_query_value(age: Age) -> &'static str {
39    match age {
40        Age::Child => "child",
41        Age::Teenager => "teenager",
42        Age::YoungAdult => "young_adult",
43        Age::MiddleAge => "middle_age",
44        Age::Elder => "elder",
45    }
46}
47
48/// Convert a [`UseCase`] into the wire format string used in query parameters.
49fn use_case_query_value(use_case: UseCase) -> &'static str {
50    match use_case {
51        UseCase::Announcer => "Announcer",
52        UseCase::Anime => "Anime",
53        UseCase::Audiobook => "Audiobook",
54        UseCase::Conversational => "Conversational",
55        UseCase::Documentary => "Documentary",
56        UseCase::ELearning => "E-learning",
57        UseCase::Rapper => "Rapper",
58        UseCase::Game => "Game",
59        UseCase::TikTokReels => "Tiktok/Reels",
60        UseCase::News => "News",
61        UseCase::Podcast => "Podcast",
62        UseCase::Voicemail => "Voicemail",
63        UseCase::Ads => "Ads",
64    }
65}
66
67/// Default API base URL
68pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
69
70/// Default request timeout in seconds
71pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
72
73/// Configuration for the Typecast client
74#[derive(Debug, Clone)]
75pub struct ClientConfig {
76    /// API key for authentication
77    pub api_key: String,
78    /// Base URL for the API (defaults to <https://api.typecast.ai>)
79    pub base_url: String,
80    /// Request timeout duration
81    pub timeout: Duration,
82}
83
84impl Default for ClientConfig {
85    fn default() -> Self {
86        Self {
87            api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
88            base_url: env::var("TYPECAST_API_HOST").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
89            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
90        }
91    }
92}
93
94impl ClientConfig {
95    /// Create a new configuration with an API key
96    pub fn new(api_key: impl Into<String>) -> Self {
97        Self {
98            api_key: api_key.into(),
99            ..Default::default()
100        }
101    }
102
103    /// Set a custom base URL
104    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
105        self.base_url = base_url.into();
106        self
107    }
108
109    /// Set a custom timeout
110    pub fn timeout(mut self, timeout: Duration) -> Self {
111        self.timeout = timeout;
112        self
113    }
114}
115
116/// The main Typecast API client
117#[derive(Debug, Clone)]
118pub struct TypecastClient {
119    client: reqwest::Client,
120    base_url: String,
121    api_key: String,
122}
123
124impl TypecastClient {
125    /// Create a new TypecastClient with the given configuration
126    pub fn new(config: ClientConfig) -> Result<Self> {
127        let mut headers = HeaderMap::new();
128        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
129        headers.insert(
130            "X-API-KEY",
131            HeaderValue::from_str(&config.api_key)
132                .map_err(|_| TypecastError::BadRequest { 
133                    detail: "Invalid API key format".to_string() 
134                })?,
135        );
136
137        // `reqwest::Client::builder().build()` only fails if TLS init fails,
138        // which is not something we can usefully recover from at this layer.
139        let client = reqwest::Client::builder()
140            .default_headers(headers)
141            .timeout(config.timeout)
142            .build()
143            .expect("reqwest client builder should not fail");
144
145        Ok(Self {
146            client,
147            base_url: config.base_url,
148            api_key: config.api_key,
149        })
150    }
151
152    /// Create a new TypecastClient from environment variables
153    ///
154    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
155    pub fn from_env() -> Result<Self> {
156        Self::new(ClientConfig::default())
157    }
158
159    /// Create a new TypecastClient with just an API key
160    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
161        Self::new(ClientConfig::new(api_key))
162    }
163
164    /// Get the base URL
165    pub fn base_url(&self) -> &str {
166        &self.base_url
167    }
168
169    /// Get the API key (masked)
170    pub fn api_key_masked(&self) -> String {
171        if self.api_key.len() > 8 {
172            format!("{}...{}", &self.api_key[..4], &self.api_key[self.api_key.len()-4..])
173        } else {
174            "****".to_string()
175        }
176    }
177
178    /// Build a URL with optional query parameters.
179    ///
180    /// Callers must pass `None` when there are no query parameters; passing
181    /// `Some(vec![])` is not supported and will produce a trailing `?`.
182    fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
183        let base = format!("{}{}", self.base_url, path);
184        match params {
185            Some(params) => {
186                let query: Vec<String> = params
187                    .into_iter()
188                    .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
189                    .collect();
190                format!("{}?{}", base, query.join("&"))
191            }
192            None => base,
193        }
194    }
195
196    /// Handle an error response
197    async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
198        let status_code = response.status().as_u16();
199        let error_response: Option<ErrorResponse> = response.json().await.ok();
200        TypecastError::from_response(status_code, error_response)
201    }
202
203    /// Convert text to speech
204    ///
205    /// # Arguments
206    ///
207    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
208    ///
209    /// # Returns
210    ///
211    /// Returns a `TTSResponse` containing the audio data, duration, and format
212    ///
213    /// # Example
214    ///
215    /// ```no_run
216    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
217    ///
218    /// # async fn example() -> typecast_rust::Result<()> {
219    /// let client = TypecastClient::from_env()?;
220    /// let request = TTSRequest::new(
221    ///     "tc_60e5426de8b95f1d3000d7b5",
222    ///     "Hello, world!",
223    ///     TTSModel::SsfmV30,
224    /// );
225    /// let response = client.text_to_speech(&request).await?;
226    /// println!("Audio duration: {} seconds", response.duration);
227    /// # Ok(())
228    /// # }
229    /// ```
230    pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
231        let url = self.build_url("/v1/text-to-speech", None);
232        
233        let response = self.client
234            .post(&url)
235            .json(request)
236            .send()
237            .await?;
238
239        if !response.status().is_success() {
240            return Err(self.handle_error_response(response).await);
241        }
242
243        // Parse content type for format
244        let content_type = response
245            .headers()
246            .get(CONTENT_TYPE)
247            .and_then(|v| v.to_str().ok())
248            .unwrap_or("audio/wav");
249        
250        let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
251            AudioFormat::Mp3
252        } else {
253            AudioFormat::Wav
254        };
255
256        // Parse duration from header
257        let duration = response
258            .headers()
259            .get("X-Audio-Duration")
260            .and_then(|v| v.to_str().ok())
261            .and_then(|v| v.parse::<f64>().ok())
262            .unwrap_or(0.0);
263
264        let audio_data = response.bytes().await?.to_vec();
265
266        Ok(TTSResponse {
267            audio_data,
268            duration,
269            format,
270        })
271    }
272
273    /// Convert text to speech as a streaming response
274    ///
275    /// Returns a stream of audio byte chunks. For `wav` output the first chunk
276    /// contains the WAV header followed by PCM samples; for `mp3` output each
277    /// chunk is independently decodable.
278    ///
279    /// # Arguments
280    ///
281    /// * `request` - The streaming TTS request
282    ///
283    /// # Returns
284    ///
285    /// A pinned boxed [`Stream`] yielding [`Result<Bytes>`] chunks.
286    ///
287    /// # Example
288    ///
289    /// ```no_run
290    /// use futures_util::StreamExt;
291    /// use typecast_rust::{TypecastClient, TTSRequestStream, TTSModel};
292    ///
293    /// # async fn example() -> typecast_rust::Result<()> {
294    /// let client = TypecastClient::from_env()?;
295    /// let request = TTSRequestStream::new(
296    ///     "tc_60e5426de8b95f1d3000d7b5",
297    ///     "Hello, world!",
298    ///     TTSModel::SsfmV30,
299    /// );
300    /// let mut stream = client.text_to_speech_stream(&request).await?;
301    /// while let Some(chunk) = stream.next().await {
302    ///     let bytes = chunk?;
303    ///     // write bytes to file or audio sink
304    ///     let _ = bytes;
305    /// }
306    /// # Ok(())
307    /// # }
308    /// ```
309    pub async fn text_to_speech_stream(
310        &self,
311        request: &TTSRequestStream,
312    ) -> Result<AudioByteStream> {
313        let url = self.build_url("/v1/text-to-speech/stream", None);
314
315        let response = self.client.post(&url).json(request).send().await?;
316
317        if !response.status().is_success() {
318            return Err(self.handle_error_response(response).await);
319        }
320
321        let stream = response
322            .bytes_stream()
323            .map(|item| item.map_err(TypecastError::from));
324        Ok(Box::pin(stream))
325    }
326
327    /// Get voices with enhanced metadata (V2 API)
328    ///
329    /// # Arguments
330    ///
331    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
332    ///
333    /// # Returns
334    ///
335    /// Returns a list of `VoiceV2` with enhanced metadata
336    ///
337    /// # Example
338    ///
339    /// ```no_run
340    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
341    ///
342    /// # async fn example() -> typecast_rust::Result<()> {
343    /// let client = TypecastClient::from_env()?;
344    /// 
345    /// // Get all voices
346    /// let voices = client.get_voices_v2(None).await?;
347    /// 
348    /// // Get filtered voices
349    /// let filter = VoicesV2Filter::new()
350    ///     .model(TTSModel::SsfmV30)
351    ///     .gender(Gender::Female);
352    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
353    /// # Ok(())
354    /// # }
355    /// ```
356    pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
357        let mut params = Vec::new();
358
359        if let Some(f) = filter {
360            if let Some(model) = f.model {
361                params.push(("model", model_query_value(model).to_string()));
362            }
363            if let Some(gender) = f.gender {
364                params.push(("gender", gender_query_value(gender).to_string()));
365            }
366            if let Some(age) = f.age {
367                params.push(("age", age_query_value(age).to_string()));
368            }
369            if let Some(use_cases) = f.use_cases {
370                params.push(("use_cases", use_case_query_value(use_cases).to_string()));
371            }
372        }
373
374        let url = self.build_url("/v2/voices", if params.is_empty() { None } else { Some(params) });
375
376        let response = self.client
377            .get(&url)
378            .send()
379            .await?;
380
381        if !response.status().is_success() {
382            return Err(self.handle_error_response(response).await);
383        }
384
385        let voices: Vec<VoiceV2> = response.json().await?;
386        Ok(voices)
387    }
388
389    /// Get a specific voice by ID with enhanced metadata (V2 API)
390    ///
391    /// # Arguments
392    ///
393    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
394    ///
395    /// # Returns
396    ///
397    /// Returns a `VoiceV2` with enhanced metadata
398    ///
399    /// # Example
400    ///
401    /// ```no_run
402    /// use typecast_rust::{TypecastClient, ClientConfig};
403    ///
404    /// # async fn example() -> typecast_rust::Result<()> {
405    /// let client = TypecastClient::from_env()?;
406    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
407    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
408    /// # Ok(())
409    /// # }
410    /// ```
411    pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
412        let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
413
414        let response = self.client
415            .get(&url)
416            .send()
417            .await?;
418
419        if !response.status().is_success() {
420            return Err(self.handle_error_response(response).await);
421        }
422
423        let voice: VoiceV2 = response.json().await?;
424        Ok(voice)
425    }
426
427    /// Convert text to speech with word- and/or character-level timestamps.
428    ///
429    /// # Arguments
430    ///
431    /// * `request` - The TTS request with timestamps parameters.
432    /// * `granularity` - Optional granularity: `None` (both), `"word"`, or `"char"`.
433    ///
434    /// # Returns
435    ///
436    /// A [`crate::timestamps::TTSWithTimestampsResponse`] containing the Base64-encoded audio
437    /// and alignment segment arrays.  Use the response's `.to_srt()` / `.to_vtt()` methods to
438    /// generate subtitle files.
439    ///
440    /// # Example
441    ///
442    /// ```no_run
443    /// use typecast_rust::{TypecastClient, TTSModel};
444    /// use typecast_rust::timestamps::TTSRequestWithTimestamps;
445    ///
446    /// # async fn example() -> typecast_rust::Result<()> {
447    /// let client = TypecastClient::from_env()?;
448    /// let request = TTSRequestWithTimestamps::new(
449    ///     "tc_60e5426de8b95f1d3000d7b5",
450    ///     "Hello, world!",
451    ///     TTSModel::SsfmV30,
452    /// );
453    /// let response = client.text_to_speech_with_timestamps(&request, None).await?;
454    /// let srt = response.to_srt()?;
455    /// println!("{}", srt);
456    /// # Ok(())
457    /// # }
458    /// ```
459    pub async fn text_to_speech_with_timestamps(
460        &self,
461        request: &crate::timestamps::TTSRequestWithTimestamps,
462        granularity: Option<&str>,
463    ) -> Result<crate::timestamps::TTSWithTimestampsResponse> {
464        if let Some(g) = granularity {
465            if g != "word" && g != "char" {
466                return Err(TypecastError::ValidationError {
467                    detail: format!(
468                        "granularity must be None, \"word\", or \"char\"; got {:?}",
469                        g
470                    ),
471                });
472            }
473        }
474
475        let url = match granularity {
476            Some(g) => self.build_url(
477                "/v1/text-to-speech/with-timestamps",
478                Some(vec![("granularity", g.to_string())]),
479            ),
480            None => self.build_url("/v1/text-to-speech/with-timestamps", None),
481        };
482
483        let response = self.client.post(&url).json(request).send().await?;
484
485        if !response.status().is_success() {
486            return Err(self.handle_error_response(response).await);
487        }
488
489        let parsed: crate::timestamps::TTSWithTimestampsResponse =
490            response.json().await.map_err(|e| TypecastError::DecodeError(e.to_string()))?;
491        Ok(parsed)
492    }
493
494    /// Get the authenticated user's subscription
495    ///
496    /// # Returns
497    ///
498    /// Returns a `SubscriptionResponse` containing the user's plan, credits,
499    /// and usage limits.
500    ///
501    /// # Example
502    ///
503    /// ```no_run
504    /// use typecast_rust::TypecastClient;
505    ///
506    /// # async fn example() -> typecast_rust::Result<()> {
507    /// let client = TypecastClient::from_env()?;
508    /// let subscription = client.get_my_subscription().await?;
509    /// println!("Plan: {:?}", subscription.plan);
510    /// println!(
511    ///     "Credits: {}/{}",
512    ///     subscription.credits.used_credits, subscription.credits.plan_credits
513    /// );
514    /// # Ok(())
515    /// # }
516    /// ```
517    pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
518        let url = self.build_url("/v1/users/me/subscription", None);
519
520        let response = self.client
521            .get(&url)
522            .send()
523            .await?;
524
525        if !response.status().is_success() {
526            return Err(self.handle_error_response(response).await);
527        }
528
529        let subscription: SubscriptionResponse = response.json().await?;
530        Ok(subscription)
531    }
532
533    /// Clone a voice from an audio recording.
534    ///
535    /// Uploads the audio file as `multipart/form-data` to `POST /v1/voices/clone`
536    /// and returns a [`CustomVoice`] representing the newly created voice.
537    ///
538    /// # Arguments
539    ///
540    /// * `audio` - Raw audio bytes (WAV or MP3). Must not exceed 25 MB.
541    /// * `filename` - File name used in the multipart part (e.g. `"sample.wav"`).
542    ///   The extension determines the MIME type sent to the API.
543    /// * `name` - Display name for the custom voice (1–30 characters).
544    /// * `model` - TTS model to use for cloning (e.g. `"ssfm-v30"`).
545    ///
546    /// # Errors
547    ///
548    /// Returns [`TypecastError::ValidationError`] if `name` is outside 1–30 characters
549    /// or if `audio` exceeds the 25 MB limit before any network call is made.
550    ///
551    /// # Example
552    ///
553    /// ```no_run
554    /// use typecast_rust::TypecastClient;
555    ///
556    /// # async fn example() -> typecast_rust::Result<()> {
557    /// let client = TypecastClient::from_env()?;
558    /// let audio = std::fs::read("sample.wav").unwrap();
559    /// let voice = client.clone_voice(audio, "sample.wav", "My Voice", "ssfm-v30").await?;
560    /// println!("Cloned voice ID: {}", voice.voice_id);
561    /// # Ok(())
562    /// # }
563    /// ```
564    pub async fn clone_voice(
565        &self,
566        audio: Vec<u8>,
567        filename: &str,
568        name: &str,
569        model: &str,
570    ) -> Result<CustomVoice> {
571        let name_len = name.chars().count();
572        if !(NAME_MIN_LENGTH..=NAME_MAX_LENGTH).contains(&name_len) {
573            return Err(TypecastError::ValidationError {
574                detail: format!(
575                    "name must be {}-{} characters; got {}",
576                    NAME_MIN_LENGTH, NAME_MAX_LENGTH, name_len
577                ),
578            });
579        }
580        if audio.len() > CLONING_MAX_FILE_SIZE {
581            return Err(TypecastError::ValidationError {
582                detail: format!(
583                    "audio file exceeds 25MB limit; got {} bytes",
584                    audio.len()
585                ),
586            });
587        }
588
589        let mime = guess_audio_mime(filename);
590        let part = reqwest::multipart::Part::bytes(audio)
591            .file_name(filename.to_string())
592            .mime_str(mime)
593            .expect("guess_audio_mime only returns valid MIME constants");
594        let form = reqwest::multipart::Form::new()
595            .text("name", name.to_string())
596            .text("model", model.to_string())
597            .part("file", part);
598
599        let url = self.build_url("/v1/voices/clone", None);
600        let response = self
601            .client
602            .post(&url)
603            .header("X-API-KEY", &self.api_key)
604            .multipart(form)
605            .send()
606            .await?;
607
608        if !response.status().is_success() {
609            return Err(self.handle_error_response(response).await);
610        }
611
612        let voice: CustomVoice = response.json().await?;
613        Ok(voice)
614    }
615
616    /// Delete a custom (cloned) voice by its ID.
617    ///
618    /// Sends `DELETE /v1/voices/{voice_id}`. A 204 No Content response is
619    /// treated as success; any other non-2xx status is mapped to a
620    /// [`TypecastError`].
621    ///
622    /// # Arguments
623    ///
624    /// * `voice_id` - The voice ID returned by [`TypecastClient::clone_voice`].
625    ///
626    /// # Example
627    ///
628    /// ```no_run
629    /// use typecast_rust::TypecastClient;
630    ///
631    /// # async fn example() -> typecast_rust::Result<()> {
632    /// let client = TypecastClient::from_env()?;
633    /// client.delete_voice("cv_abc123").await?;
634    /// println!("Voice deleted.");
635    /// # Ok(())
636    /// # }
637    /// ```
638    pub async fn delete_voice(&self, voice_id: &str) -> Result<()> {
639        let url = self.build_url(&format!("/v1/voices/{}", voice_id), None);
640        let response = self
641            .client
642            .delete(&url)
643            .header("X-API-KEY", &self.api_key)
644            .send()
645            .await?;
646
647        let status = response.status();
648        if !status.is_success() {
649            return Err(self.handle_error_response(response).await);
650        }
651        Ok(())
652    }
653}
654
655/// Infer an audio MIME type from a filename extension.
656///
657/// Defaults to `application/octet-stream` for unrecognised extensions.
658fn guess_audio_mime(filename: &str) -> &'static str {
659    let lower = filename.to_lowercase();
660    if lower.ends_with(".wav") {
661        "audio/wav"
662    } else if lower.ends_with(".mp3") {
663        "audio/mpeg"
664    } else if lower.ends_with(".ogg") {
665        "audio/ogg"
666    } else if lower.ends_with(".flac") {
667        "audio/flac"
668    } else if lower.ends_with(".m4a") {
669        "audio/mp4"
670    } else {
671        "application/octet-stream"
672    }
673}
674
675/// URL encoding helper
676mod urlencoding {
677    pub fn encode(s: &str) -> String {
678        url_encode(s)
679    }
680
681    fn url_encode(s: &str) -> String {
682        let mut result = String::new();
683        for c in s.chars() {
684            match c {
685                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
686                    result.push(c);
687                }
688                _ => {
689                    for b in c.to_string().as_bytes() {
690                        result.push_str(&format!("%{:02X}", b));
691                    }
692                }
693            }
694        }
695        result
696    }
697}