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}