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}