Skip to main content

quantum_sdk/
voices.rs

1use serde::{Deserialize, Serialize};
2
3use crate::client::Client;
4use crate::error::Result;
5use crate::keys::StatusResponse;
6
7/// A voice available for TTS.
8#[derive(Debug, Clone, Deserialize)]
9pub struct Voice {
10    /// Voice identifier.
11    pub voice_id: String,
12
13    /// Human-readable voice name.
14    pub name: String,
15
16    /// Provider (e.g. "elevenlabs", "openai").
17    #[serde(default)]
18    pub provider: Option<String>,
19
20    /// Language/locale codes supported.
21    #[serde(default)]
22    pub languages: Option<Vec<String>>,
23
24    /// Voice gender.
25    #[serde(default)]
26    pub gender: Option<String>,
27
28    /// Whether this is a cloned voice.
29    #[serde(default)]
30    pub is_cloned: Option<bool>,
31
32    /// Preview audio URL.
33    #[serde(default)]
34    pub preview_url: Option<String>,
35}
36
37/// Response from listing voices.
38#[derive(Debug, Clone, Deserialize)]
39pub struct VoicesResponse {
40    /// Available voices.
41    pub voices: Vec<Voice>,
42}
43
44/// A file to include in a voice clone request.
45#[derive(Debug, Clone)]
46pub struct CloneVoiceFile {
47    /// Original filename (e.g. "sample.mp3").
48    pub filename: String,
49
50    /// Raw file bytes.
51    pub data: Vec<u8>,
52
53    /// MIME type (e.g. "audio/mpeg").
54    pub mime_type: String,
55}
56
57/// Response from cloning a voice.
58#[derive(Debug, Clone, Deserialize)]
59pub struct CloneVoiceResponse {
60    /// The new voice identifier.
61    pub voice_id: String,
62
63    /// The name assigned to the cloned voice.
64    pub name: String,
65
66    /// Status message.
67    #[serde(default)]
68    pub status: Option<String>,
69}
70
71// ---------------------------------------------------------------------------
72// Voice Library (shared/community voices)
73// ---------------------------------------------------------------------------
74
75/// A shared voice from the voice library.
76#[derive(Debug, Clone, Deserialize)]
77pub struct SharedVoice {
78    /// Owner's public identifier.
79    pub public_owner_id: String,
80
81    /// Voice identifier.
82    pub voice_id: String,
83
84    /// Voice display name.
85    pub name: String,
86
87    /// Voice category (e.g. "professional", "generated").
88    #[serde(default)]
89    pub category: Option<String>,
90
91    /// Voice description.
92    #[serde(default)]
93    pub description: Option<String>,
94
95    /// Preview audio URL.
96    #[serde(default)]
97    pub preview_url: Option<String>,
98
99    /// Voice gender.
100    #[serde(default)]
101    pub gender: Option<String>,
102
103    /// Perceived age range.
104    #[serde(default)]
105    pub age: Option<String>,
106
107    /// Accent (e.g. "british", "american").
108    #[serde(default)]
109    pub accent: Option<String>,
110
111    /// Primary language.
112    #[serde(default)]
113    pub language: Option<String>,
114
115    /// Intended use case (e.g. "narration", "conversational").
116    #[serde(default)]
117    pub use_case: Option<String>,
118
119    /// Average rating.
120    #[serde(default)]
121    pub rate: Option<f64>,
122
123    /// Number of times this voice has been cloned.
124    #[serde(default)]
125    pub cloned_by_count: Option<i64>,
126
127    /// Whether free-tier users can use this voice.
128    #[serde(default)]
129    pub free_users_allowed: Option<bool>,
130}
131
132/// Response from browsing the voice library.
133#[derive(Debug, Clone, Deserialize)]
134pub struct SharedVoicesResponse {
135    /// Shared voices matching the query.
136    pub voices: Vec<SharedVoice>,
137
138    /// Cursor for pagination (pass as `cursor` in next request).
139    #[serde(default)]
140    pub next_cursor: Option<String>,
141
142    /// Whether more results are available.
143    #[serde(default)]
144    pub has_more: bool,
145}
146
147/// Request parameters for browsing the voice library.
148#[derive(Debug, Clone, Serialize, Default)]
149pub struct VoiceLibraryQuery {
150    /// Search query string.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub query: Option<String>,
153
154    /// Maximum number of results per page.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub page_size: Option<i32>,
157
158    /// Pagination cursor from a previous response.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub cursor: Option<String>,
161
162    /// Filter by gender.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub gender: Option<String>,
165
166    /// Filter by language.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub language: Option<String>,
169
170    /// Filter by use case.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub use_case: Option<String>,
173}
174
175/// Response from adding a voice from the library.
176#[derive(Debug, Clone, Deserialize)]
177pub struct AddVoiceFromLibraryResponse {
178    /// The voice ID added to the user's account.
179    pub voice_id: String,
180}
181
182/// Simple percent-encoding for query parameter values.
183fn encode_query_value(s: &str) -> String {
184    let mut encoded = String::with_capacity(s.len());
185    for b in s.bytes() {
186        match b {
187            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
188                encoded.push(b as char);
189            }
190            _ => {
191                encoded.push_str(&format!("%{b:02X}"));
192            }
193        }
194    }
195    encoded
196}
197
198impl Client {
199    /// Lists all available TTS voices (built-in and cloned).
200    pub async fn list_voices(&self) -> Result<VoicesResponse> {
201        let (resp, _meta) = self
202            .get_json::<VoicesResponse>("/qai/v1/voices")
203            .await?;
204        Ok(resp)
205    }
206
207    /// Clones a voice from audio samples.
208    ///
209    /// Sends audio files as multipart form data along with a name for the new voice.
210    pub async fn clone_voice(
211        &self,
212        name: &str,
213        files: Vec<CloneVoiceFile>,
214    ) -> Result<CloneVoiceResponse> {
215        let mut form = reqwest::multipart::Form::new().text("name", name.to_string());
216
217        for file in files {
218            let part = reqwest::multipart::Part::bytes(file.data)
219                .file_name(file.filename)
220                .mime_str(&file.mime_type)
221                .map_err(|e| crate::error::Error::Http(e.into()))?;
222            form = form.part("files", part);
223        }
224
225        let (resp, _meta) = self
226            .post_multipart::<CloneVoiceResponse>("/qai/v1/voices/clone", form)
227            .await?;
228        Ok(resp)
229    }
230
231    /// Deletes a cloned voice by its ID.
232    pub async fn delete_voice(&self, id: &str) -> Result<StatusResponse> {
233        let path = format!("/qai/v1/voices/{id}");
234        let (resp, _meta) = self.delete_json::<StatusResponse>(&path).await?;
235        Ok(resp)
236    }
237
238    /// Browses the shared voice library with optional filters.
239    pub async fn voice_library(
240        &self,
241        query: &VoiceLibraryQuery,
242    ) -> Result<SharedVoicesResponse> {
243        let mut params = Vec::new();
244        if let Some(ref q) = query.query {
245            params.push(format!("query={}", encode_query_value(q)));
246        }
247        if let Some(ps) = query.page_size {
248            params.push(format!("page_size={ps}"));
249        }
250        if let Some(ref c) = query.cursor {
251            params.push(format!("cursor={}", encode_query_value(c)));
252        }
253        if let Some(ref g) = query.gender {
254            params.push(format!("gender={}", encode_query_value(g)));
255        }
256        if let Some(ref l) = query.language {
257            params.push(format!("language={}", encode_query_value(l)));
258        }
259        if let Some(ref u) = query.use_case {
260            params.push(format!("use_case={}", encode_query_value(u)));
261        }
262
263        let path = if params.is_empty() {
264            "/qai/v1/voices/library".to_string()
265        } else {
266            format!("/qai/v1/voices/library?{}", params.join("&"))
267        };
268
269        let (resp, _meta) = self
270            .get_json::<SharedVoicesResponse>(&path)
271            .await?;
272        Ok(resp)
273    }
274
275    /// Adds a shared voice from the library to the user's account.
276    pub async fn add_voice_from_library(
277        &self,
278        public_owner_id: &str,
279        voice_id: &str,
280        name: Option<&str>,
281    ) -> Result<AddVoiceFromLibraryResponse> {
282        let mut body = serde_json::json!({
283            "public_owner_id": public_owner_id,
284            "voice_id": voice_id,
285        });
286        if let Some(n) = name {
287            body["name"] = serde_json::Value::String(n.to_string());
288        }
289        let (resp, _meta) = self
290            .post_json::<serde_json::Value, AddVoiceFromLibraryResponse>(
291                "/qai/v1/voices/library/add",
292                &body,
293            )
294            .await?;
295        Ok(resp)
296    }
297}