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 std::env;
16use std::fs;
17use std::path::Path;
18use std::pin::Pin;
19use std::time::Duration;
20
21pub type AudioByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
23
24fn model_query_value(model: TTSModel) -> &'static str {
26 match model {
27 TTSModel::SsfmV30 => "ssfm-v30",
28 TTSModel::SsfmV21 => "ssfm-v21",
29 }
30}
31
32fn gender_query_value(gender: Gender) -> &'static str {
34 match gender {
35 Gender::Male => "male",
36 Gender::Female => "female",
37 }
38}
39
40fn age_query_value(age: Age) -> &'static str {
42 match age {
43 Age::Child => "child",
44 Age::Teenager => "teenager",
45 Age::YoungAdult => "young_adult",
46 Age::MiddleAge => "middle_age",
47 Age::Elder => "elder",
48 }
49}
50
51fn use_case_query_value(use_case: UseCase) -> &'static str {
53 match use_case {
54 UseCase::Announcer => "Announcer",
55 UseCase::Anime => "Anime",
56 UseCase::Audiobook => "Audiobook",
57 UseCase::Conversational => "Conversational",
58 UseCase::Documentary => "Documentary",
59 UseCase::ELearning => "E-learning",
60 UseCase::Rapper => "Rapper",
61 UseCase::Game => "Game",
62 UseCase::TikTokReels => "Tiktok/Reels",
63 UseCase::News => "News",
64 UseCase::Podcast => "Podcast",
65 UseCase::Voicemail => "Voicemail",
66 UseCase::Ads => "Ads",
67 }
68}
69
70fn infer_audio_format_from_path(path: &Path) -> Option<AudioFormat> {
71 match path.extension().and_then(|extension| extension.to_str()) {
72 Some(extension) if extension.eq_ignore_ascii_case("mp3") => Some(AudioFormat::Mp3),
73 Some(extension) if extension.eq_ignore_ascii_case("wav") => Some(AudioFormat::Wav),
74 _ => None,
75 }
76}
77
78pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
80
81pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
83
84#[derive(Debug, Clone)]
86pub struct ClientConfig {
87 pub api_key: String,
89 pub base_url: String,
91 pub timeout: Duration,
93}
94
95impl Default for ClientConfig {
96 fn default() -> Self {
97 Self {
98 api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
99 base_url: env::var("TYPECAST_API_HOST")
100 .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
101 timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
102 }
103 }
104}
105
106impl ClientConfig {
107 pub fn new(api_key: impl Into<String>) -> Self {
109 Self {
110 api_key: api_key.into(),
111 ..Default::default()
112 }
113 }
114
115 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
117 self.base_url = base_url.into();
118 self
119 }
120
121 pub fn timeout(mut self, timeout: Duration) -> Self {
123 self.timeout = timeout;
124 self
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct TypecastClient {
131 client: reqwest::Client,
132 base_url: String,
133 api_key: String,
134}
135
136impl TypecastClient {
137 pub fn new(config: ClientConfig) -> Result<Self> {
139 let api_key = config.api_key.trim().to_string();
140 let base_url = config.base_url.trim().trim_end_matches('/').to_string();
141 if api_key.is_empty() && is_default_base_url(&base_url) {
142 return Err(TypecastError::Unauthorized {
143 detail: "API key is required for the default Typecast API host".to_string(),
144 });
145 }
146
147 let mut headers = HeaderMap::new();
148 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
149 headers.insert(
150 USER_AGENT,
151 HeaderValue::from_str(&build_user_agent(&base_url, config.timeout))
152 .expect("SDK-generated User-Agent is valid ASCII"),
153 );
154 if !api_key.is_empty() {
155 headers.insert(
156 "X-API-KEY",
157 HeaderValue::from_str(&api_key).map_err(|_| TypecastError::BadRequest {
158 detail: "Invalid API key format".to_string(),
159 })?,
160 );
161 }
162
163 let client = reqwest::Client::builder()
166 .default_headers(headers)
167 .timeout(config.timeout)
168 .build()
169 .expect("reqwest client builder should not fail");
170
171 Ok(Self {
172 client,
173 base_url,
174 api_key,
175 })
176 }
177
178 pub fn from_env() -> Result<Self> {
182 Self::new(ClientConfig::default())
183 }
184
185 pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
187 Self::new(ClientConfig::new(api_key))
188 }
189
190 pub fn base_url(&self) -> &str {
192 &self.base_url
193 }
194
195 pub fn api_key_masked(&self) -> String {
197 if self.api_key.len() > 8 {
198 format!(
199 "{}...{}",
200 &self.api_key[..4],
201 &self.api_key[self.api_key.len() - 4..]
202 )
203 } else {
204 "****".to_string()
205 }
206 }
207
208 pub fn compose_speech(&self) -> SpeechComposer<'_> {
210 SpeechComposer::new(self)
211 }
212
213 fn with_auth_header(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
214 if self.api_key.is_empty() {
215 request
216 } else {
217 request.header("X-API-KEY", &self.api_key)
218 }
219 }
220
221 fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
226 let base = format!("{}{}", self.base_url, path);
227 match params {
228 Some(params) => {
229 let query: Vec<String> = params
230 .into_iter()
231 .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
232 .collect();
233 format!("{}?{}", base, query.join("&"))
234 }
235 None => base,
236 }
237 }
238
239 async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
241 let status_code = response.status().as_u16();
242 let error_response: Option<ErrorResponse> = response.json().await.ok();
243 TypecastError::from_response(status_code, error_response)
244 }
245
246 pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
274 let url = self.build_url("/v1/text-to-speech", None);
275
276 let response = self.client.post(&url).json(request).send().await?;
277
278 if !response.status().is_success() {
279 return Err(self.handle_error_response(response).await);
280 }
281
282 let content_type = response
284 .headers()
285 .get(CONTENT_TYPE)
286 .and_then(|v| v.to_str().ok())
287 .unwrap_or("audio/wav");
288
289 let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
290 AudioFormat::Mp3
291 } else {
292 AudioFormat::Wav
293 };
294
295 let duration = response
297 .headers()
298 .get("X-Audio-Duration")
299 .and_then(|v| v.to_str().ok())
300 .and_then(|v| v.parse::<f64>().ok())
301 .unwrap_or(0.0);
302
303 let audio_data = response.bytes().await?.to_vec();
304
305 Ok(TTSResponse {
306 audio_data,
307 duration,
308 format,
309 })
310 }
311
312 pub async fn generate_to_file(
317 &self,
318 path: impl AsRef<Path>,
319 request: GenerateToFileRequest,
320 ) -> Result<TTSResponse> {
321 let path_ref = path.as_ref();
322 let mut tts_request = request.into_tts_request();
323 let inferred = infer_audio_format_from_path(path_ref);
324 match tts_request.output.as_mut() {
325 Some(output) => {
326 if output.audio_format.is_none() {
327 output.audio_format = inferred;
328 }
329 }
330 None => {
331 if let Some(format) = inferred {
332 tts_request.output = Some(crate::models::Output::new().audio_format(format));
333 }
334 }
335 }
336
337 let response = self.text_to_speech(&tts_request).await?;
338 fs::write(path_ref, &response.audio_data)
339 .map_err(|e| TypecastError::IoError(e.to_string()))?;
340 Ok(response)
341 }
342
343 pub async fn text_to_speech_stream(
380 &self,
381 request: &TTSRequestStream,
382 ) -> Result<AudioByteStream> {
383 let url = self.build_url("/v1/text-to-speech/stream", None);
384
385 let response = self.client.post(&url).json(request).send().await?;
386
387 if !response.status().is_success() {
388 return Err(self.handle_error_response(response).await);
389 }
390
391 let stream = response
392 .bytes_stream()
393 .map(|item| item.map_err(TypecastError::from));
394 Ok(Box::pin(stream))
395 }
396
397 pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
427 let mut params = Vec::new();
428
429 if let Some(f) = filter {
430 if let Some(model) = f.model {
431 params.push(("model", model_query_value(model).to_string()));
432 }
433 if let Some(gender) = f.gender {
434 params.push(("gender", gender_query_value(gender).to_string()));
435 }
436 if let Some(age) = f.age {
437 params.push(("age", age_query_value(age).to_string()));
438 }
439 if let Some(use_cases) = f.use_cases {
440 params.push(("use_cases", use_case_query_value(use_cases).to_string()));
441 }
442 }
443
444 let url = self.build_url(
445 "/v2/voices",
446 if params.is_empty() {
447 None
448 } else {
449 Some(params)
450 },
451 );
452
453 let response = self.client.get(&url).send().await?;
454
455 if !response.status().is_success() {
456 return Err(self.handle_error_response(response).await);
457 }
458
459 let voices: Vec<VoiceV2> = response.json().await?;
460 Ok(voices)
461 }
462
463 pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
486 let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
487
488 let response = self.client.get(&url).send().await?;
489
490 if !response.status().is_success() {
491 return Err(self.handle_error_response(response).await);
492 }
493
494 let voice: VoiceV2 = response.json().await?;
495 Ok(voice)
496 }
497
498 pub async fn recommend_voices(
504 &self,
505 query: &str,
506 count: Option<u8>,
507 ) -> Result<Vec<RecommendedVoice>> {
508 let count = count.unwrap_or(5);
509 if !(1..=10).contains(&count) {
510 return Err(TypecastError::ValidationError {
511 detail: "count must be between 1 and 10".to_string(),
512 });
513 }
514
515 let url = self.build_url(
516 "/v1/voices/recommendations",
517 Some(vec![
518 ("query", query.to_string()),
519 ("count", count.to_string()),
520 ]),
521 );
522
523 let response = self.client.get(&url).send().await?;
524
525 if !response.status().is_success() {
526 return Err(self.handle_error_response(response).await);
527 }
528
529 let voices: Vec<RecommendedVoice> = response.json().await?;
530 Ok(voices)
531 }
532
533 pub async fn text_to_speech_with_timestamps(
566 &self,
567 request: &crate::timestamps::TTSRequestWithTimestamps,
568 granularity: Option<&str>,
569 ) -> Result<crate::timestamps::TTSWithTimestampsResponse> {
570 if let Some(g) = granularity {
571 if g != "word" && g != "char" {
572 return Err(TypecastError::ValidationError {
573 detail: format!(
574 "granularity must be None, \"word\", or \"char\"; got {:?}",
575 g
576 ),
577 });
578 }
579 }
580
581 let url = match granularity {
582 Some(g) => self.build_url(
583 "/v1/text-to-speech/with-timestamps",
584 Some(vec![("granularity", g.to_string())]),
585 ),
586 None => self.build_url("/v1/text-to-speech/with-timestamps", None),
587 };
588
589 let response = self.client.post(&url).json(request).send().await?;
590
591 if !response.status().is_success() {
592 return Err(self.handle_error_response(response).await);
593 }
594
595 let parsed: crate::timestamps::TTSWithTimestampsResponse = response
596 .json()
597 .await
598 .map_err(|e| TypecastError::DecodeError(e.to_string()))?;
599 Ok(parsed)
600 }
601
602 pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
626 let url = self.build_url("/v1/users/me/subscription", None);
627
628 let response = self.client.get(&url).send().await?;
629
630 if !response.status().is_success() {
631 return Err(self.handle_error_response(response).await);
632 }
633
634 let subscription: SubscriptionResponse = response.json().await?;
635 Ok(subscription)
636 }
637
638 pub async fn clone_voice(
670 &self,
671 audio: Vec<u8>,
672 filename: &str,
673 name: &str,
674 model: &str,
675 ) -> Result<CustomVoice> {
676 let name_len = name.chars().count();
677 if !(NAME_MIN_LENGTH..=NAME_MAX_LENGTH).contains(&name_len) {
678 return Err(TypecastError::ValidationError {
679 detail: format!(
680 "name must be {}-{} characters; got {}",
681 NAME_MIN_LENGTH, NAME_MAX_LENGTH, name_len
682 ),
683 });
684 }
685 if audio.len() > CLONING_MAX_FILE_SIZE {
686 return Err(TypecastError::ValidationError {
687 detail: format!("audio file exceeds 25MB limit; got {} bytes", audio.len()),
688 });
689 }
690
691 let mime = guess_audio_mime(filename);
692 let part = reqwest::multipart::Part::bytes(audio)
693 .file_name(filename.to_string())
694 .mime_str(mime)
695 .expect("guess_audio_mime only returns valid MIME constants");
696 let form = reqwest::multipart::Form::new()
697 .text("name", name.to_string())
698 .text("model", model.to_string())
699 .part("file", part);
700
701 let url = self.build_url("/v1/voices/clone", None);
702 let response = self
703 .with_auth_header(self.client.post(&url))
704 .multipart(form)
705 .send()
706 .await?;
707
708 if !response.status().is_success() {
709 return Err(self.handle_error_response(response).await);
710 }
711
712 let voice: CustomVoice = response.json().await?;
713 Ok(voice)
714 }
715
716 pub async fn delete_voice(&self, voice_id: &str) -> Result<()> {
739 let url = self.build_url(&format!("/v1/voices/{}", voice_id), None);
740 let response = self
741 .with_auth_header(self.client.delete(&url))
742 .send()
743 .await?;
744
745 let status = response.status();
746 if !status.is_success() {
747 return Err(self.handle_error_response(response).await);
748 }
749 Ok(())
750 }
751}
752
753fn build_user_agent(base_url: &str, timeout: Duration) -> String {
754 let base = if is_default_base_url(base_url) {
755 "default"
756 } else {
757 "custom"
758 };
759 let timeout_value = if timeout == Duration::from_secs(DEFAULT_TIMEOUT_SECS) {
760 "default".to_string()
761 } else {
762 format!("{}ms", timeout.as_millis())
763 };
764 format!(
765 "typecast-rust/{} Rust/{} reqwest (base={}; timeout={}; os={}; arch={}; sdk_env=rust; platform=server)",
766 env!("CARGO_PKG_VERSION"),
767 rust_version(),
768 base,
769 timeout_value,
770 os_name(),
771 arch_name()
772 )
773}
774
775fn rust_version() -> &'static str {
776 "unknown"
777}
778
779fn os_name() -> &'static str {
780 normalize_os_name(env::consts::OS)
781}
782
783fn normalize_os_name(os: &str) -> &'static str {
784 match os {
785 "macos" => "macos",
786 "windows" => "windows",
787 "linux" => "linux",
788 "ios" => "ios",
789 "android" => "android",
790 _ => "unknown",
791 }
792}
793
794fn arch_name() -> &'static str {
795 normalize_arch_name(env::consts::ARCH)
796}
797
798fn normalize_arch_name(arch: &str) -> &'static str {
799 match arch {
800 "x86_64" => "x64",
801 "aarch64" => "arm64",
802 "x86" => "x86",
803 "arm" => "arm",
804 _ => "unknown",
805 }
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811
812 #[test]
813 fn user_agent_includes_sdk_metadata_and_base_timeout_context() {
814 let default_user_agent =
815 build_user_agent(DEFAULT_BASE_URL, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
816 assert!(default_user_agent.starts_with("typecast-rust/"));
817 assert!(default_user_agent.contains("base=default"));
818 assert!(default_user_agent.contains("timeout=default"));
819 assert!(default_user_agent.contains("sdk_env=rust; platform=server"));
820
821 let custom_user_agent = build_user_agent("https://proxy.example", Duration::from_secs(5));
822 assert!(custom_user_agent.contains("base=custom"));
823 assert!(custom_user_agent.contains("timeout=5000ms"));
824 }
825
826 #[test]
827 fn platform_metadata_normalizes_known_and_unknown_values() {
828 assert_eq!(normalize_os_name("macos"), "macos");
829 assert_eq!(normalize_os_name("windows"), "windows");
830 assert_eq!(normalize_os_name("linux"), "linux");
831 assert_eq!(normalize_os_name("ios"), "ios");
832 assert_eq!(normalize_os_name("android"), "android");
833 assert_eq!(normalize_os_name("solaris"), "unknown");
834
835 assert_eq!(normalize_arch_name("x86_64"), "x64");
836 assert_eq!(normalize_arch_name("aarch64"), "arm64");
837 assert_eq!(normalize_arch_name("x86"), "x86");
838 assert_eq!(normalize_arch_name("arm"), "arm");
839 assert_eq!(normalize_arch_name("mips"), "unknown");
840 }
841}
842
843fn guess_audio_mime(filename: &str) -> &'static str {
847 let lower = filename.to_lowercase();
848 if lower.ends_with(".wav") {
849 "audio/wav"
850 } else if lower.ends_with(".mp3") {
851 "audio/mpeg"
852 } else if lower.ends_with(".ogg") {
853 "audio/ogg"
854 } else if lower.ends_with(".flac") {
855 "audio/flac"
856 } else if lower.ends_with(".m4a") {
857 "audio/mp4"
858 } else {
859 "application/octet-stream"
860 }
861}
862
863fn is_default_base_url(base_url: &str) -> bool {
864 base_url.eq_ignore_ascii_case(DEFAULT_BASE_URL)
865}
866
867mod urlencoding {
869 pub fn encode(s: &str) -> String {
870 url_encode(s)
871 }
872
873 fn url_encode(s: &str) -> String {
874 let mut result = String::new();
875 for c in s.chars() {
876 match c {
877 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
878 result.push(c);
879 }
880 _ => {
881 for b in c.to_string().as_bytes() {
882 result.push_str(&format!("%{:02X}", b));
883 }
884 }
885 }
886 }
887 result
888 }
889}