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}