1use crate::composer::SpeechComposer;
6use crate::errors::{Result, TypecastError};
7use crate::models::{
8 Age, AudioFormat, CustomVoice, ErrorResponse, Gender, GenerateToFileRequest, RecommendedVoice,
9 SubscriptionResponse, TTSModel, TTSRequest, TTSRequestStream, TTSResponse, UseCase, VoiceV2,
10 VoicesV2Filter, CLONING_MAX_FILE_SIZE, NAME_MAX_LENGTH, NAME_MIN_LENGTH,
11};
12use bytes::Bytes;
13use futures_util::stream::{Stream, StreamExt};
14use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, USER_AGENT};
15use serde::Serialize;
16use std::env;
17use std::fs;
18use std::path::Path;
19use std::pin::Pin;
20use std::time::Duration;
21
22pub type AudioByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
24
25fn model_query_value(model: TTSModel) -> &'static str {
27 match model {
28 TTSModel::SsfmV30 => "ssfm-v30",
29 TTSModel::SsfmV21 => "ssfm-v21",
30 }
31}
32
33fn gender_query_value(gender: Gender) -> &'static str {
35 match gender {
36 Gender::Male => "male",
37 Gender::Female => "female",
38 }
39}
40
41fn age_query_value(age: Age) -> &'static str {
43 match age {
44 Age::Child => "child",
45 Age::Teenager => "teenager",
46 Age::YoungAdult => "young_adult",
47 Age::MiddleAge => "middle_age",
48 Age::Elder => "elder",
49 }
50}
51
52fn use_case_query_value(use_case: UseCase) -> &'static str {
54 match use_case {
55 UseCase::Announcer => "Announcer",
56 UseCase::Anime => "Anime",
57 UseCase::Audiobook => "Audiobook",
58 UseCase::Conversational => "Conversational",
59 UseCase::Documentary => "Documentary",
60 UseCase::ELearning => "E-learning",
61 UseCase::Rapper => "Rapper",
62 UseCase::Game => "Game",
63 UseCase::TikTokReels => "Tiktok/Reels",
64 UseCase::News => "News",
65 UseCase::Podcast => "Podcast",
66 UseCase::Voicemail => "Voicemail",
67 UseCase::Ads => "Ads",
68 }
69}
70
71fn infer_audio_format_from_path(path: &Path) -> Option<AudioFormat> {
72 match path.extension().and_then(|extension| extension.to_str()) {
73 Some(extension) if extension.eq_ignore_ascii_case("mp3") => Some(AudioFormat::Mp3),
74 Some(extension) if extension.eq_ignore_ascii_case("wav") => Some(AudioFormat::Wav),
75 _ => None,
76 }
77}
78
79pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
81
82pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
84
85#[derive(Debug, Clone)]
87pub struct ClientConfig {
88 pub api_key: String,
90 pub base_url: String,
92 pub timeout: Duration,
94}
95
96impl Default for ClientConfig {
97 fn default() -> Self {
98 Self {
99 api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
100 base_url: env::var("TYPECAST_API_HOST")
101 .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
102 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
103 }
104 }
105}
106
107impl ClientConfig {
108 pub fn new(api_key: impl Into<String>) -> Self {
110 Self {
111 api_key: api_key.into(),
112 ..Default::default()
113 }
114 }
115
116 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
118 self.base_url = base_url.into();
119 self
120 }
121
122 pub fn timeout(mut self, timeout: Duration) -> Self {
124 self.timeout = timeout;
125 self
126 }
127}
128
129#[derive(Debug, Clone)]
131pub struct TypecastClient {
132 client: reqwest::Client,
133 base_url: String,
134 api_key: String,
135}
136
137impl TypecastClient {
138 pub fn new(config: ClientConfig) -> Result<Self> {
140 let api_key = config.api_key.trim().to_string();
141 let base_url = config.base_url.trim().trim_end_matches('/').to_string();
142 if api_key.is_empty() && is_default_base_url(&base_url) {
143 return Err(TypecastError::Unauthorized {
144 detail: "API key is required for the default Typecast API host".to_string(),
145 });
146 }
147
148 let mut headers = HeaderMap::new();
149 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
150 headers.insert(
151 USER_AGENT,
152 HeaderValue::from_str(&build_user_agent(&base_url, config.timeout))
153 .expect("SDK-generated User-Agent is valid ASCII"),
154 );
155 if !api_key.is_empty() {
156 headers.insert(
157 "X-API-KEY",
158 HeaderValue::from_str(&api_key).map_err(|_| TypecastError::BadRequest {
159 detail: "Invalid API key format".to_string(),
160 })?,
161 );
162 }
163
164 let client = reqwest::Client::builder()
167 .default_headers(headers)
168 .timeout(config.timeout)
169 .build()
170 .expect("reqwest client builder should not fail");
171
172 Ok(Self {
173 client,
174 base_url,
175 api_key,
176 })
177 }
178
179 pub fn from_env() -> Result<Self> {
183 Self::new(ClientConfig::default())
184 }
185
186 pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
188 Self::new(ClientConfig::new(api_key))
189 }
190
191 pub fn base_url(&self) -> &str {
193 &self.base_url
194 }
195
196 pub fn api_key_masked(&self) -> String {
198 if self.api_key.len() > 8 {
199 format!(
200 "{}...{}",
201 &self.api_key[..4],
202 &self.api_key[self.api_key.len() - 4..]
203 )
204 } else {
205 "****".to_string()
206 }
207 }
208
209 pub fn compose_speech(&self) -> SpeechComposer<'_> {
211 SpeechComposer::new(self)
212 }
213
214 fn with_auth_header(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
215 if self.api_key.is_empty() {
216 request
217 } else {
218 request.header("X-API-KEY", &self.api_key)
219 }
220 }
221
222 fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
227 let base = format!("{}{}", self.base_url, path);
228 match params {
229 Some(params) => {
230 let query: Vec<String> = params
231 .into_iter()
232 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
233 .collect();
234 format!("{}?{}", base, query.join("&"))
235 }
236 None => base,
237 }
238 }
239
240 async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
242 let status_code = response.status().as_u16();
243 let error_response: Option<ErrorResponse> = response.json().await.ok();
244 TypecastError::from_response(status_code, error_response)
245 }
246
247 pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
275 let url = self.build_url("/v1/text-to-speech", None);
276
277 let response = self.client.post(&url).json(request).send().await?;
278
279 if !response.status().is_success() {
280 return Err(self.handle_error_response(response).await);
281 }
282
283 let content_type = response
285 .headers()
286 .get(CONTENT_TYPE)
287 .and_then(|v| v.to_str().ok())
288 .unwrap_or("audio/wav");
289
290 let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
291 AudioFormat::Mp3
292 } else {
293 AudioFormat::Wav
294 };
295
296 let duration = response
298 .headers()
299 .get("X-Audio-Duration")
300 .and_then(|v| v.to_str().ok())
301 .and_then(|v| v.parse::<f64>().ok())
302 .unwrap_or(0.0);
303
304 let audio_data = response.bytes().await?.to_vec();
305
306 Ok(TTSResponse {
307 audio_data,
308 duration,
309 format,
310 })
311 }
312
313 pub(crate) async fn compose_text_to_speech(
314 &self,
315 request: &impl Serialize,
316 ) -> Result<TTSResponse> {
317 let url = self.build_url("/v1/text-to-speech/compose", None);
318 let response = self.client.post(&url).json(request).send().await?;
319 if !response.status().is_success() {
320 return Err(self.handle_error_response(response).await);
321 }
322 let format = response
323 .headers()
324 .get(CONTENT_TYPE)
325 .and_then(|v| v.to_str().ok())
326 .filter(|v| {
327 let normalized = v.to_ascii_lowercase();
328 normalized.contains("mp3") || normalized.contains("mpeg")
329 })
330 .map(|_| AudioFormat::Mp3)
331 .unwrap_or(AudioFormat::Wav);
332 let duration = response
333 .headers()
334 .get("X-Audio-Duration")
335 .and_then(|v| v.to_str().ok())
336 .and_then(|v| v.parse::<f64>().ok())
337 .unwrap_or(0.0);
338 Ok(TTSResponse {
339 audio_data: response.bytes().await?.to_vec(),
340 duration,
341 format,
342 })
343 }
344
345 pub async fn generate_to_file(
350 &self,
351 path: impl AsRef<Path>,
352 request: GenerateToFileRequest,
353 ) -> Result<TTSResponse> {
354 let path_ref = path.as_ref();
355 let mut tts_request = request.into_tts_request();
356 let inferred = infer_audio_format_from_path(path_ref);
357 match tts_request.output.as_mut() {
358 Some(output) => {
359 if output.audio_format.is_none() {
360 output.audio_format = inferred;
361 }
362 }
363 None => {
364 if let Some(format) = inferred {
365 tts_request.output = Some(crate::models::Output::new().audio_format(format));
366 }
367 }
368 }
369
370 let response = self.text_to_speech(&tts_request).await?;
371 fs::write(path_ref, &response.audio_data)
372 .map_err(|e| TypecastError::IoError(e.to_string()))?;
373 Ok(response)
374 }
375
376 pub async fn text_to_speech_stream(
413 &self,
414 request: &TTSRequestStream,
415 ) -> Result<AudioByteStream> {
416 let url = self.build_url("/v1/text-to-speech/stream", None);
417
418 let response = self.client.post(&url).json(request).send().await?;
419
420 if !response.status().is_success() {
421 return Err(self.handle_error_response(response).await);
422 }
423
424 let stream = response
425 .bytes_stream()
426 .map(|item| item.map_err(TypecastError::from));
427 Ok(Box::pin(stream))
428 }
429
430 pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
460 let mut params = Vec::new();
461
462 if let Some(f) = filter {
463 if let Some(model) = f.model {
464 params.push(("model", model_query_value(model).to_string()));
465 }
466 if let Some(gender) = f.gender {
467 params.push(("gender", gender_query_value(gender).to_string()));
468 }
469 if let Some(age) = f.age {
470 params.push(("age", age_query_value(age).to_string()));
471 }
472 if let Some(use_cases) = f.use_cases {
473 params.push(("use_cases", use_case_query_value(use_cases).to_string()));
474 }
475 }
476
477 let url = self.build_url(
478 "/v2/voices",
479 if params.is_empty() {
480 None
481 } else {
482 Some(params)
483 },
484 );
485
486 let response = self.client.get(&url).send().await?;
487
488 if !response.status().is_success() {
489 return Err(self.handle_error_response(response).await);
490 }
491
492 let voices: Vec<VoiceV2> = response.json().await?;
493 Ok(voices)
494 }
495
496 pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
519 let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
520
521 let response = self.client.get(&url).send().await?;
522
523 if !response.status().is_success() {
524 return Err(self.handle_error_response(response).await);
525 }
526
527 let voice: VoiceV2 = response.json().await?;
528 Ok(voice)
529 }
530
531 pub async fn recommend_voices(
537 &self,
538 query: &str,
539 count: Option<u8>,
540 ) -> Result<Vec<RecommendedVoice>> {
541 let count = count.unwrap_or(5);
542 if !(1..=10).contains(&count) {
543 return Err(TypecastError::ValidationError {
544 detail: "count must be between 1 and 10".to_string(),
545 });
546 }
547
548 let url = self.build_url(
549 "/v1/voices/recommendations",
550 Some(vec![
551 ("query", query.to_string()),
552 ("count", count.to_string()),
553 ]),
554 );
555
556 let response = self.client.get(&url).send().await?;
557
558 if !response.status().is_success() {
559 return Err(self.handle_error_response(response).await);
560 }
561
562 let voices: Vec<RecommendedVoice> = response.json().await?;
563 Ok(voices)
564 }
565
566 pub async fn text_to_speech_with_timestamps(
599 &self,
600 request: &crate::timestamps::TTSRequestWithTimestamps,
601 granularity: Option<&str>,
602 ) -> Result<crate::timestamps::TTSWithTimestampsResponse> {
603 if let Some(g) = granularity {
604 if g != "word" && g != "char" {
605 return Err(TypecastError::ValidationError {
606 detail: format!(
607 "granularity must be None, \"word\", or \"char\"; got {:?}",
608 g
609 ),
610 });
611 }
612 }
613
614 let url = match granularity {
615 Some(g) => self.build_url(
616 "/v1/text-to-speech/with-timestamps",
617 Some(vec![("granularity", g.to_string())]),
618 ),
619 None => self.build_url("/v1/text-to-speech/with-timestamps", None),
620 };
621
622 let response = self.client.post(&url).json(request).send().await?;
623
624 if !response.status().is_success() {
625 return Err(self.handle_error_response(response).await);
626 }
627
628 let parsed: crate::timestamps::TTSWithTimestampsResponse = response
629 .json()
630 .await
631 .map_err(|e| TypecastError::DecodeError(e.to_string()))?;
632 Ok(parsed)
633 }
634
635 pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
659 let url = self.build_url("/v1/users/me/subscription", None);
660
661 let response = self.client.get(&url).send().await?;
662
663 if !response.status().is_success() {
664 return Err(self.handle_error_response(response).await);
665 }
666
667 let subscription: SubscriptionResponse = response.json().await?;
668 Ok(subscription)
669 }
670
671 pub async fn clone_voice(
703 &self,
704 audio: Vec<u8>,
705 filename: &str,
706 name: &str,
707 model: &str,
708 ) -> Result<CustomVoice> {
709 let name_len = name.chars().count();
710 if !(NAME_MIN_LENGTH..=NAME_MAX_LENGTH).contains(&name_len) {
711 return Err(TypecastError::ValidationError {
712 detail: format!(
713 "name must be {}-{} characters; got {}",
714 NAME_MIN_LENGTH, NAME_MAX_LENGTH, name_len
715 ),
716 });
717 }
718 if audio.len() > CLONING_MAX_FILE_SIZE {
719 return Err(TypecastError::ValidationError {
720 detail: format!("audio file exceeds 25MB limit; got {} bytes", audio.len()),
721 });
722 }
723
724 let mime = guess_audio_mime(filename);
725 let part = reqwest::multipart::Part::bytes(audio)
726 .file_name(filename.to_string())
727 .mime_str(mime)
728 .expect("guess_audio_mime only returns valid MIME constants");
729 let form = reqwest::multipart::Form::new()
730 .text("name", name.to_string())
731 .text("model", model.to_string())
732 .part("file", part);
733
734 let url = self.build_url("/v1/voices/clone", None);
735 let response = self
736 .with_auth_header(self.client.post(&url))
737 .multipart(form)
738 .send()
739 .await?;
740
741 if !response.status().is_success() {
742 return Err(self.handle_error_response(response).await);
743 }
744
745 let voice: CustomVoice = response.json().await?;
746 Ok(voice)
747 }
748
749 pub async fn delete_voice(&self, voice_id: &str) -> Result<()> {
772 let url = self.build_url(&format!("/v1/voices/{}", voice_id), None);
773 let response = self
774 .with_auth_header(self.client.delete(&url))
775 .send()
776 .await?;
777
778 let status = response.status();
779 if !status.is_success() {
780 return Err(self.handle_error_response(response).await);
781 }
782 Ok(())
783 }
784}
785
786fn build_user_agent(base_url: &str, timeout: Duration) -> String {
787 let base = if is_default_base_url(base_url) {
788 "default"
789 } else {
790 "custom"
791 };
792 let timeout_value = if timeout == Duration::from_secs(DEFAULT_TIMEOUT_SECS) {
793 "default".to_string()
794 } else {
795 format!("{}ms", timeout.as_millis())
796 };
797 format!(
798 "typecast-rust/{} Rust/{} reqwest (base={}; timeout={}; os={}; arch={}; sdk_env=rust; platform=server)",
799 env!("CARGO_PKG_VERSION"),
800 rust_version(),
801 base,
802 timeout_value,
803 os_name(),
804 arch_name()
805 )
806}
807
808fn rust_version() -> &'static str {
809 "unknown"
810}
811
812fn os_name() -> &'static str {
813 normalize_os_name(env::consts::OS)
814}
815
816fn normalize_os_name(os: &str) -> &'static str {
817 match os {
818 "macos" => "macos",
819 "windows" => "windows",
820 "linux" => "linux",
821 "ios" => "ios",
822 "android" => "android",
823 _ => "unknown",
824 }
825}
826
827fn arch_name() -> &'static str {
828 normalize_arch_name(env::consts::ARCH)
829}
830
831fn normalize_arch_name(arch: &str) -> &'static str {
832 match arch {
833 "x86_64" => "x64",
834 "aarch64" => "arm64",
835 "x86" => "x86",
836 "arm" => "arm",
837 _ => "unknown",
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844
845 #[test]
846 fn user_agent_includes_sdk_metadata_and_base_timeout_context() {
847 let default_user_agent =
848 build_user_agent(DEFAULT_BASE_URL, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
849 assert!(default_user_agent.starts_with("typecast-rust/"));
850 assert!(default_user_agent.contains("base=default"));
851 assert!(default_user_agent.contains("timeout=default"));
852 assert!(default_user_agent.contains("sdk_env=rust; platform=server"));
853
854 let custom_user_agent = build_user_agent("https://proxy.example", Duration::from_secs(5));
855 assert!(custom_user_agent.contains("base=custom"));
856 assert!(custom_user_agent.contains("timeout=5000ms"));
857 }
858
859 #[test]
860 fn platform_metadata_normalizes_known_and_unknown_values() {
861 assert_eq!(normalize_os_name("macos"), "macos");
862 assert_eq!(normalize_os_name("windows"), "windows");
863 assert_eq!(normalize_os_name("linux"), "linux");
864 assert_eq!(normalize_os_name("ios"), "ios");
865 assert_eq!(normalize_os_name("android"), "android");
866 assert_eq!(normalize_os_name("solaris"), "unknown");
867
868 assert_eq!(normalize_arch_name("x86_64"), "x64");
869 assert_eq!(normalize_arch_name("aarch64"), "arm64");
870 assert_eq!(normalize_arch_name("x86"), "x86");
871 assert_eq!(normalize_arch_name("arm"), "arm");
872 assert_eq!(normalize_arch_name("mips"), "unknown");
873 }
874}
875
876fn guess_audio_mime(filename: &str) -> &'static str {
880 let lower = filename.to_lowercase();
881 if lower.ends_with(".wav") {
882 "audio/wav"
883 } else if lower.ends_with(".mp3") {
884 "audio/mpeg"
885 } else if lower.ends_with(".ogg") {
886 "audio/ogg"
887 } else if lower.ends_with(".flac") {
888 "audio/flac"
889 } else if lower.ends_with(".m4a") {
890 "audio/mp4"
891 } else {
892 "application/octet-stream"
893 }
894}
895
896fn is_default_base_url(base_url: &str) -> bool {
897 base_url.eq_ignore_ascii_case(DEFAULT_BASE_URL)
898}
899
900mod urlencoding {
902 pub fn encode(s: &str) -> String {
903 url_encode(s)
904 }
905
906 fn url_encode(s: &str) -> String {
907 let mut result = String::new();
908 for c in s.chars() {
909 match c {
910 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
911 result.push(c);
912 }
913 _ => {
914 for b in c.to_string().as_bytes() {
915 result.push_str(&format!("%{:02X}", b));
916 }
917 }
918 }
919 }
920 result
921 }
922}