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