Skip to main content

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::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
22/// Boxed asynchronous stream of audio chunks returned by the streaming TTS endpoint.
23pub type AudioByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
24
25/// Convert a [`TTSModel`] into the wire format string used in query parameters.
26fn model_query_value(model: TTSModel) -> &'static str {
27    match model {
28        TTSModel::SsfmV30 => "ssfm-v30",
29        TTSModel::SsfmV21 => "ssfm-v21",
30    }
31}
32
33/// Convert a [`Gender`] into the wire format string used in query parameters.
34fn gender_query_value(gender: Gender) -> &'static str {
35    match gender {
36        Gender::Male => "male",
37        Gender::Female => "female",
38    }
39}
40
41/// Convert an [`Age`] into the wire format string used in query parameters.
42fn 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
52/// Convert a [`UseCase`] into the wire format string used in query parameters.
53fn 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
79/// Default API base URL
80pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
81
82/// Default request timeout in seconds
83pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
84
85/// Configuration for the Typecast client
86#[derive(Debug, Clone)]
87pub struct ClientConfig {
88    /// API key for authentication. Optional when using a proxy base URL.
89    pub api_key: String,
90    /// Base URL for the API (defaults to <https://api.typecast.ai>)
91    pub base_url: String,
92    /// Request timeout duration
93    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    /// Create a new configuration with an API key
109    pub fn new(api_key: impl Into<String>) -> Self {
110        Self {
111            api_key: api_key.into(),
112            ..Default::default()
113        }
114    }
115
116    /// Set a custom base URL
117    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
118        self.base_url = base_url.into();
119        self
120    }
121
122    /// Set a custom timeout
123    pub fn timeout(mut self, timeout: Duration) -> Self {
124        self.timeout = timeout;
125        self
126    }
127}
128
129/// The main Typecast API client
130#[derive(Debug, Clone)]
131pub struct TypecastClient {
132    client: reqwest::Client,
133    base_url: String,
134    api_key: String,
135}
136
137impl TypecastClient {
138    /// Create a new TypecastClient with the given configuration
139    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        // `reqwest::Client::builder().build()` only fails if TLS init fails,
165        // which is not something we can usefully recover from at this layer.
166        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    /// Create a new TypecastClient from environment variables
180    ///
181    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
182    pub fn from_env() -> Result<Self> {
183        Self::new(ClientConfig::default())
184    }
185
186    /// Create a new TypecastClient with just an API key
187    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
188        Self::new(ClientConfig::new(api_key))
189    }
190
191    /// Get the base URL
192    pub fn base_url(&self) -> &str {
193        &self.base_url
194    }
195
196    /// Get the API key (masked)
197    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    /// Create a builder for composed speech with multiple speech segments and pauses.
210    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    /// Build a URL with optional query parameters.
223    ///
224    /// Callers must pass `None` when there are no query parameters; passing
225    /// `Some(vec![])` is not supported and will produce a trailing `?`.
226    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    /// Handle an error response
241    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    /// Convert text to speech
248    ///
249    /// # Arguments
250    ///
251    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
252    ///
253    /// # Returns
254    ///
255    /// Returns a `TTSResponse` containing the audio data, duration, and format
256    ///
257    /// # Example
258    ///
259    /// ```no_run
260    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
261    ///
262    /// # async fn example() -> typecast_rust::Result<()> {
263    /// let client = TypecastClient::from_env()?;
264    /// let request = TTSRequest::new(
265    ///     "tc_60e5426de8b95f1d3000d7b5",
266    ///     "Hello, world!",
267    ///     TTSModel::SsfmV30,
268    /// );
269    /// let response = client.text_to_speech(&request).await?;
270    /// println!("Audio duration: {} seconds", response.duration);
271    /// # Ok(())
272    /// # }
273    /// ```
274    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        // Parse content type for format
284        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        // Parse duration from header
297        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    /// Convert text to speech and write the audio bytes to a file.
346    ///
347    /// If `request.output.audio_format` is omitted, the format is inferred from
348    /// a `.mp3` or `.wav` file extension.
349    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    /// Convert text to speech as a streaming response
377    ///
378    /// Returns a stream of audio byte chunks. For `wav` output the first chunk
379    /// contains the WAV header followed by PCM samples; for `mp3` output each
380    /// chunk is independently decodable.
381    ///
382    /// # Arguments
383    ///
384    /// * `request` - The streaming TTS request
385    ///
386    /// # Returns
387    ///
388    /// A pinned boxed [`Stream`] yielding [`Result<Bytes>`] chunks.
389    ///
390    /// # Example
391    ///
392    /// ```no_run
393    /// use futures_util::StreamExt;
394    /// use typecast_rust::{TypecastClient, TTSRequestStream, TTSModel};
395    ///
396    /// # async fn example() -> typecast_rust::Result<()> {
397    /// let client = TypecastClient::from_env()?;
398    /// let request = TTSRequestStream::new(
399    ///     "tc_60e5426de8b95f1d3000d7b5",
400    ///     "Hello, world!",
401    ///     TTSModel::SsfmV30,
402    /// );
403    /// let mut stream = client.text_to_speech_stream(&request).await?;
404    /// while let Some(chunk) = stream.next().await {
405    ///     let bytes = chunk?;
406    ///     // write bytes to file or audio sink
407    ///     let _ = bytes;
408    /// }
409    /// # Ok(())
410    /// # }
411    /// ```
412    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    /// Get voices with enhanced metadata (V2 API)
431    ///
432    /// # Arguments
433    ///
434    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
435    ///
436    /// # Returns
437    ///
438    /// Returns a list of `VoiceV2` with enhanced metadata
439    ///
440    /// # Example
441    ///
442    /// ```no_run
443    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
444    ///
445    /// # async fn example() -> typecast_rust::Result<()> {
446    /// let client = TypecastClient::from_env()?;
447    ///
448    /// // Get all voices
449    /// let voices = client.get_voices_v2(None).await?;
450    ///
451    /// // Get filtered voices
452    /// let filter = VoicesV2Filter::new()
453    ///     .model(TTSModel::SsfmV30)
454    ///     .gender(Gender::Female);
455    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
456    /// # Ok(())
457    /// # }
458    /// ```
459    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    /// Get a specific voice by ID with enhanced metadata (V2 API)
497    ///
498    /// # Arguments
499    ///
500    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
501    ///
502    /// # Returns
503    ///
504    /// Returns a `VoiceV2` with enhanced metadata
505    ///
506    /// # Example
507    ///
508    /// ```no_run
509    /// use typecast_rust::{TypecastClient, ClientConfig};
510    ///
511    /// # async fn example() -> typecast_rust::Result<()> {
512    /// let client = TypecastClient::from_env()?;
513    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
514    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
515    /// # Ok(())
516    /// # }
517    /// ```
518    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    /// Recommend voices from a text description.
532    ///
533    /// Results only contain `voice_id`, `voice_name`, and `score`. Use
534    /// `get_voice_v2` or `get_voices_v2` when you need detailed metadata for
535    /// the returned voice IDs.
536    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    /// Convert text to speech with word- and/or character-level timestamps.
567    ///
568    /// # Arguments
569    ///
570    /// * `request` - The TTS request with timestamps parameters.
571    /// * `granularity` - Optional granularity: `None` (both), `"word"`, or `"char"`.
572    ///
573    /// # Returns
574    ///
575    /// A [`crate::timestamps::TTSWithTimestampsResponse`] containing the Base64-encoded audio
576    /// and alignment segment arrays.  Use the response's `.to_srt()` / `.to_vtt()` methods to
577    /// generate subtitle files.
578    ///
579    /// # Example
580    ///
581    /// ```no_run
582    /// use typecast_rust::{TypecastClient, TTSModel};
583    /// use typecast_rust::timestamps::TTSRequestWithTimestamps;
584    ///
585    /// # async fn example() -> typecast_rust::Result<()> {
586    /// let client = TypecastClient::from_env()?;
587    /// let request = TTSRequestWithTimestamps::new(
588    ///     "tc_60e5426de8b95f1d3000d7b5",
589    ///     "Hello, world!",
590    ///     TTSModel::SsfmV30,
591    /// );
592    /// let response = client.text_to_speech_with_timestamps(&request, None).await?;
593    /// let srt = response.to_srt()?;
594    /// println!("{}", srt);
595    /// # Ok(())
596    /// # }
597    /// ```
598    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    /// Get the authenticated user's subscription
636    ///
637    /// # Returns
638    ///
639    /// Returns a `SubscriptionResponse` containing the user's plan, credits,
640    /// and usage limits.
641    ///
642    /// # Example
643    ///
644    /// ```no_run
645    /// use typecast_rust::TypecastClient;
646    ///
647    /// # async fn example() -> typecast_rust::Result<()> {
648    /// let client = TypecastClient::from_env()?;
649    /// let subscription = client.get_my_subscription().await?;
650    /// println!("Plan: {:?}", subscription.plan);
651    /// println!(
652    ///     "Credits: {}/{}",
653    ///     subscription.credits.used_credits, subscription.credits.plan_credits
654    /// );
655    /// # Ok(())
656    /// # }
657    /// ```
658    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    /// Clone a voice from an audio recording.
672    ///
673    /// Uploads the audio file as `multipart/form-data` to `POST /v1/voices/clone`
674    /// and returns a [`CustomVoice`] representing the newly created voice.
675    ///
676    /// # Arguments
677    ///
678    /// * `audio` - Raw audio bytes (WAV or MP3). Must not exceed 25 MB.
679    /// * `filename` - File name used in the multipart part (e.g. `"sample.wav"`).
680    ///   The extension determines the MIME type sent to the API.
681    /// * `name` - Display name for the custom voice (1–30 characters).
682    /// * `model` - TTS model to use for cloning (e.g. `"ssfm-v30"`).
683    ///
684    /// # Errors
685    ///
686    /// Returns [`TypecastError::ValidationError`] if `name` is outside 1–30 characters
687    /// or if `audio` exceeds the 25 MB limit before any network call is made.
688    ///
689    /// # Example
690    ///
691    /// ```no_run
692    /// use typecast_rust::TypecastClient;
693    ///
694    /// # async fn example() -> typecast_rust::Result<()> {
695    /// let client = TypecastClient::from_env()?;
696    /// let audio = std::fs::read("sample.wav").unwrap();
697    /// let voice = client.clone_voice(audio, "sample.wav", "My Voice", "ssfm-v30").await?;
698    /// println!("Cloned voice ID: {}", voice.voice_id);
699    /// # Ok(())
700    /// # }
701    /// ```
702    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    /// Delete a custom (cloned) voice by its ID.
750    ///
751    /// Sends `DELETE /v1/voices/{voice_id}`. A 204 No Content response is
752    /// treated as success; any other non-2xx status is mapped to a
753    /// [`TypecastError`].
754    ///
755    /// # Arguments
756    ///
757    /// * `voice_id` - The voice ID returned by [`TypecastClient::clone_voice`].
758    ///
759    /// # Example
760    ///
761    /// ```no_run
762    /// use typecast_rust::TypecastClient;
763    ///
764    /// # async fn example() -> typecast_rust::Result<()> {
765    /// let client = TypecastClient::from_env()?;
766    /// client.delete_voice("cv_abc123").await?;
767    /// println!("Voice deleted.");
768    /// # Ok(())
769    /// # }
770    /// ```
771    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
876/// Infer an audio MIME type from a filename extension.
877///
878/// Defaults to `application/octet-stream` for unrecognised extensions.
879fn 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
900/// URL encoding helper
901mod 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}