Skip to main content

xil_api/
elevenlabs.rs

1//! The ElevenLabs endpoints the pipeline calls, shaped as `elevenlabs`
2//! 2.x sends them.
3
4use 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/// `ElevenLabs(api_key=os.environ.get("ELEVENLABS_API_KEY"))`.
11#[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    /// From `ELEVENLABS_API_KEY`, absent or not.
26    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        // The SDK only sends the header when it was given a key.
37        if let Some(k) = &self.api_key {
38            req = req.set("xi-api-key", k);
39        }
40        req
41    }
42
43    /// `client.user.get()`.
44    pub fn user_get(&self) -> Result<Value, ApiError> {
45        read_json(call_logged("GET", self.request("GET", "/v1/user").call())?)
46    }
47
48    /// `client.voices.get_all()`.
49    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    /// `client.text_to_speech.convert(voice_id, text=, model_id=,
57    /// output_format=, voice_settings=)` collected into bytes.
58    ///
59    /// `voice_settings`: `None` leaves the field out; `Some(Value::Null)`
60    /// sends `null`, which is what passing `voice_settings=None` explicitly
61    /// does in the SDK.
62    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    /// `client.text_to_sound_effects.convert(text=, duration_seconds=,
86    /// prompt_influence=)` collected into bytes.
87    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    /// One page of `GET /v1/sound-generation/history`, as `XILU005` fetches
111    /// it with plain httpx (not the SDK).
112    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    /// `client.studio.projects.create(...)` — a multipart form.
128    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
160/// Percent-encode a path segment the way httpx would.
161fn 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}