1use serde_json::{Map, Value};
5
6use crate::{agent, base_url, call_logged, read_bytes, read_json, send_json, ApiError};
7
8pub const DEFAULT_BASE_URL: &str = "https://api.elevenlabs.io";
9
10#[derive(Clone, Debug)]
12pub struct Client {
13 api_key: Option<String>,
14 base: String,
15}
16
17impl Client {
18 pub fn new(api_key: Option<String>) -> Client {
19 Client {
20 api_key: api_key.filter(|k| !k.is_empty()),
21 base: base_url("XIL_ELEVENLABS_BASE_URL", DEFAULT_BASE_URL),
22 }
23 }
24
25 pub fn from_env() -> Client {
27 Client::new(std::env::var("ELEVENLABS_API_KEY").ok())
28 }
29
30 pub fn has_key(&self) -> bool {
31 self.api_key.is_some()
32 }
33
34 fn request(&self, method: &str, path: &str) -> ureq::Request {
35 let mut req = agent().request(method, &format!("{}{path}", self.base));
36 if let Some(k) = &self.api_key {
38 req = req.set("xi-api-key", k);
39 }
40 req
41 }
42
43 pub fn user_get(&self) -> Result<Value, ApiError> {
45 read_json(call_logged("GET", self.request("GET", "/v1/user").call())?)
46 }
47
48 pub fn voices_get_all(&self) -> Result<Value, ApiError> {
50 read_json(call_logged(
51 "GET",
52 self.request("GET", "/v1/voices").call(),
53 )?)
54 }
55
56 pub fn text_to_speech(
63 &self,
64 voice_id: &str,
65 text: &str,
66 model_id: &str,
67 output_format: &str,
68 voice_settings: Option<Value>,
69 ) -> Result<Vec<u8>, ApiError> {
70 let mut body = Map::new();
71 body.insert("text".into(), text.into());
72 body.insert("model_id".into(), model_id.into());
73 if let Some(vs) = voice_settings {
74 body.insert("voice_settings".into(), vs);
75 }
76 let req = self
77 .request(
78 "POST",
79 &format!("/v1/text-to-speech/{}", encode_path(voice_id)),
80 )
81 .query("output_format", output_format);
82 read_bytes(call_logged("POST", send_json(req, &Value::Object(body)))?)
83 }
84
85 pub fn sound_generation(
88 &self,
89 text: &str,
90 duration_seconds: Option<f64>,
91 prompt_influence: Option<f64>,
92 ) -> Result<Vec<u8>, ApiError> {
93 let mut body = Map::new();
94 body.insert("text".into(), text.into());
95 if let Some(d) = duration_seconds {
96 body.insert("duration_seconds".into(), num(d));
97 }
98 if let Some(p) = prompt_influence {
99 body.insert("prompt_influence".into(), num(p));
100 }
101 read_bytes(call_logged(
102 "POST",
103 send_json(
104 self.request("POST", "/v1/sound-generation"),
105 &Value::Object(body),
106 ),
107 )?)
108 }
109
110 pub fn sound_generation_history(
113 &self,
114 page_size: u32,
115 start_after: Option<&str>,
116 ) -> Result<Value, ApiError> {
117 let mut req = self
118 .request("GET", "/v1/sound-generation/history")
119 .set("accept", "application/json")
120 .query("page_size", &page_size.to_string());
121 if let Some(id) = start_after {
122 req = req.query("start_after_history_item_id", id);
123 }
124 read_json(call_logged("GET", req.call())?)
125 }
126
127 pub fn studio_projects_create(
129 &self,
130 fields: &[(&str, String)],
131 file: Option<(&str, &str, Vec<u8>)>,
132 ) -> Result<Value, ApiError> {
133 let boundary = "xil-rxil-boundary-7c1f0e5a";
134 let mut body = Vec::new();
135 for (name, value) in fields {
136 body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n").as_bytes());
137 }
138 if let Some((name, filename, data)) = file {
139 body.extend_from_slice(
140 format!("--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes(),
141 );
142 body.extend_from_slice(&data);
143 body.extend_from_slice(b"\r\n");
144 }
145 body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
146 let req = self.request("POST", "/v1/studio/projects").set(
147 "Content-Type",
148 &format!("multipart/form-data; boundary={boundary}"),
149 );
150 read_json(call_logged("POST", req.send_bytes(&body))?)
151 }
152}
153
154fn num(x: f64) -> Value {
155 serde_json::Number::from_f64(x)
156 .map(Value::Number)
157 .unwrap_or(Value::Null)
158}
159
160fn encode_path(s: &str) -> String {
162 let mut out = String::new();
163 for b in s.bytes() {
164 match b {
165 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
166 out.push(b as char)
167 }
168 _ => out.push_str(&format!("%{b:02X}")),
169 }
170 }
171 out
172}