Skip to main content

xil/cmd/
voices.rs

1//! `xil voices` — list the workspace's ElevenLabs voices with enriched
2//! metadata, or back-fill a cast file from them. Port of
3//! `XILU001_discover_voices_T2S.py`.
4
5use std::ffi::OsString;
6use std::fs;
7
8use crate::cmd::py_str;
9use clap::Parser;
10use indexmap::IndexMap;
11use serde_json::{Map, Value};
12use xil_audio::fx::py_repr;
13use xil_core::pyfmt::head;
14use xil_core::pyjson::{dumps, Style};
15use xil_core::{banner, log};
16
17#[derive(Parser)]
18#[command(
19    name = "xil-voices",
20    about = "List ElevenLabs voices with enriched metadata"
21)]
22struct Args {
23    /// Filter by category: premade cloned generated professional
24    #[arg(long, num_args = 1.., value_name = "CAT")]
25    category: Option<Vec<String>>,
26    /// Case-insensitive substring filter on name or description
27    #[arg(long, value_name = "TEXT")]
28    search: Option<String>,
29    /// Show full detail for a single voice ID
30    #[arg(long, value_name = "VOICE_ID")]
31    id: Option<String>,
32    /// Print all fields for each voice
33    #[arg(long, short = 'v')]
34    verbose: bool,
35    /// Output results as JSON array
36    #[arg(long)]
37    json: bool,
38    /// Back-fill role and language_code in a cast JSON from API voice metadata
39    #[arg(long, value_name = "CAST_JSON")]
40    update_cast: Option<String>,
41    /// With --update-cast: show changes without writing the file
42    #[arg(long)]
43    dry_run: bool,
44}
45
46fn category_label(cat: &str) -> Option<&'static str> {
47    match cat {
48        "premade" => Some("Premade"),
49        "cloned" => Some("Instant Clone"),
50        "generated" => Some("Generated"),
51        "professional" => Some("Professional Clone (PVC)"),
52        _ => None,
53    }
54}
55
56fn sharing_label(cat: &str) -> Option<&'static str> {
57    match cat {
58        "professional" => Some("Professional Clone"),
59        "high_quality" => Some("High Quality"),
60        _ => None,
61    }
62}
63
64fn truthy(v: &Value) -> bool {
65    crate::cmd::truthy(v)
66}
67
68fn s(v: &Value) -> &str {
69    v.as_str().unwrap_or("")
70}
71
72/// `_fmt_unix(ts)` — `YYYY-MM-DD` in UTC.
73fn fmt_unix(ts: &Value) -> String {
74    ts.as_i64()
75        .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
76        .map(|d| d.format("%Y-%m-%d").to_string())
77        .unwrap_or_default()
78}
79
80/// `build_voice_record(v)`, keys in the Python dict's order.
81fn build_voice_record(v: &Value) -> Map<String, Value> {
82    let get = |k: &str| v.get(k).cloned().unwrap_or(Value::Null);
83    let labels = v
84        .get("labels")
85        .and_then(Value::as_object)
86        .cloned()
87        .unwrap_or_default();
88    let sharing = v.get("sharing").filter(|x| x.is_object());
89    let sfield = |k: &str| {
90        sharing
91            .and_then(|sh| sh.get(k))
92            .cloned()
93            .unwrap_or(Value::Null)
94    };
95    let sharing_cat = match sharing {
96        Some(_) => {
97            let c = sfield("category");
98            match c.as_str().and_then(sharing_label) {
99                Some(l) => Value::from(l),
100                None => c,
101            }
102        }
103        None => Value::Null,
104    };
105    let description = [get("description"), sfield("description")]
106        .into_iter()
107        .find(truthy)
108        .unwrap_or_else(|| Value::from(""));
109    let langs_all: Vec<String> = v
110        .get("verified_languages")
111        .and_then(Value::as_array)
112        .map(|a| {
113            a.iter()
114                .map(|l| {
115                    l.get("language")
116                        .map(py_str)
117                        .unwrap_or_else(|| "None".into())
118                })
119                .collect()
120        })
121        .unwrap_or_default();
122    let mut langs: Vec<String> = Vec::new();
123    for l in &langs_all {
124        if !langs.contains(l) {
125            langs.push(l.clone());
126        }
127    }
128    let lang_str = if langs.is_empty() {
129        String::new()
130    } else if langs.len() <= 3 {
131        langs.join(", ")
132    } else {
133        format!("{} (+{})", langs[..3].join(", "), langs.len() - 3)
134    };
135    let label = |k: &str| labels.get(k).cloned().unwrap_or_else(|| Value::from(""));
136
137    let mut r = Map::new();
138    r.insert("voice_id".into(), get("voice_id"));
139    r.insert("name".into(), get("name"));
140    r.insert(
141        "library_name".into(),
142        if sharing.is_some() {
143            sfield("name")
144        } else {
145            Value::Null
146        },
147    );
148    r.insert("category".into(), get("category"));
149    r.insert("sharing_category".into(), sharing_cat);
150    r.insert("description".into(), description);
151    for k in [
152        "gender",
153        "age",
154        "accent",
155        "descriptive",
156        "use_case",
157        "language",
158    ] {
159        r.insert(k.into(), label(k));
160    }
161    r.insert("verified_languages".into(), lang_str.into());
162    r.insert("verified_lang_count".into(), langs.len().into());
163    let hq = v
164        .get("high_quality_base_model_ids")
165        .filter(|x| truthy(x))
166        .cloned()
167        .unwrap_or_else(|| Value::Array(vec![]));
168    r.insert("high_quality_models".into(), hq);
169    r.insert("is_owner".into(), get("is_owner"));
170    r.insert("is_bookmarked".into(), get("is_bookmarked"));
171    r.insert("permission".into(), get("permission_on_resource"));
172    r.insert(
173        "created_at".into(),
174        fmt_unix(&get("created_at_unix")).into(),
175    );
176    r.insert(
177        "notice_days".into(),
178        if sharing.is_some() {
179            sfield("notice_period")
180        } else {
181            Value::Null
182        },
183    );
184    r
185}
186
187fn or_dash(v: &Value) -> String {
188    if truthy(v) {
189        py_str(v)
190    } else {
191        "—".into()
192    }
193}
194
195fn cat_label(rec: &Map<String, Value>) -> String {
196    let cat = &rec["category"];
197    match cat.as_str().and_then(category_label) {
198        Some(l) => l.to_string(),
199        None if truthy(cat) => py_str(cat),
200        None => "?".into(),
201    }
202}
203
204fn print_verbose(rec: &Map<String, Value>) {
205    let mut label = cat_label(rec);
206    if truthy(&rec["sharing_category"]) {
207        label = format!("{label} / {}", py_str(&rec["sharing_category"]));
208    }
209    log::info(&format!("  Name         : {}", py_str(&rec["name"])));
210    if truthy(&rec["library_name"]) && rec["library_name"] != rec["name"] {
211        log::info(&format!(
212            "  Library name : {}",
213            py_str(&rec["library_name"])
214        ));
215    }
216    log::info(&format!("  Voice ID     : {}", py_str(&rec["voice_id"])));
217    log::info(&format!("  Category     : {label}"));
218    if truthy(&rec["description"]) {
219        log::info(&format!("  Description  : {}", py_str(&rec["description"])));
220    }
221    log::info(&format!("  Gender       : {}", or_dash(&rec["gender"])));
222    log::info(&format!("  Age          : {}", or_dash(&rec["age"])));
223    log::info(&format!("  Accent       : {}", or_dash(&rec["accent"])));
224    log::info(&format!(
225        "  Tone/style   : {}",
226        or_dash(&rec["descriptive"])
227    ));
228    log::info(&format!("  Use case     : {}", or_dash(&rec["use_case"])));
229    log::info(&format!("  Language     : {}", or_dash(&rec["language"])));
230    if truthy(&rec["verified_languages"]) {
231        log::info(&format!(
232            "  Verified langs: {} ({} total)",
233            py_str(&rec["verified_languages"]),
234            py_str(&rec["verified_lang_count"])
235        ));
236    }
237    if truthy(&rec["high_quality_models"]) {
238        let models: Vec<String> = rec["high_quality_models"]
239            .as_array()
240            .into_iter()
241            .flatten()
242            .map(py_str)
243            .collect();
244        log::info(&format!("  HQ models    : {}", models.join(", ")));
245    }
246    log::info(&format!(
247        "  Owner        : {}",
248        if truthy(&rec["is_owner"]) {
249            "Yes"
250        } else {
251            "No (library copy)"
252        }
253    ));
254    log::info(&format!(
255        "  Bookmarked   : {}",
256        if truthy(&rec["is_bookmarked"]) {
257            "Yes"
258        } else {
259            "No"
260        }
261    ));
262    log::info(&format!(
263        "  Permission   : {}",
264        if truthy(&rec["permission"]) {
265            py_str(&rec["permission"])
266        } else {
267            "none".into()
268        }
269    ));
270    log::info(&format!("  Created      : {}", or_dash(&rec["created_at"])));
271    if truthy(&rec["notice_days"]) {
272        log::info(&format!(
273            "  Notice period: {} days",
274            py_str(&rec["notice_days"])
275        ));
276    }
277    log::info("");
278}
279
280fn or_q(v: &Value) -> String {
281    if truthy(v) {
282        py_str(v)
283    } else {
284        "?".into()
285    }
286}
287
288fn print_compact(rec: &Map<String, Value>) {
289    let langs = if truthy(&rec["verified_languages"]) {
290        format!(" | langs: {}", py_str(&rec["verified_languages"]))
291    } else {
292        String::new()
293    };
294    let desc = if truthy(&rec["description"]) {
295        format!(" | {}", head(s(&rec["description"]), 60))
296    } else {
297        String::new()
298    };
299    let name = xil_core::pyfmt::pad_right(&py_str(&rec["name"]), 28);
300    log::info(&format!(
301        "  {name} {}  [{}]\n    {}, {}, {}, {}{langs}{desc}",
302        py_str(&rec["voice_id"]),
303        cat_label(rec),
304        or_q(&rec["gender"]),
305        or_q(&rec["age"]),
306        or_q(&rec["accent"]),
307        or_q(&rec["descriptive"]),
308    ));
309}
310
311fn update_cast(
312    path: &str,
313    by_id: &IndexMap<String, Map<String, Value>>,
314    dry_run: bool,
315) -> anyhow::Result<()> {
316    let mut cast: Value = serde_json::from_str(&fs::read_to_string(path)?)?;
317    let mut changes: Vec<String> = Vec::new();
318    if let Some(members) = cast.get_mut("cast").and_then(Value::as_object_mut) {
319        for (key, member) in members.iter_mut() {
320            let Some(m) = member.as_object_mut() else {
321                continue;
322            };
323            let vid = m
324                .get("voice_id")
325                .cloned()
326                .unwrap_or_else(|| Value::from("TBD"));
327            if vid == "TBD" {
328                log::info(&format!("  {key}: voice_id is TBD — skipping"));
329                continue;
330            }
331            let Some(rec) = vid.as_str().and_then(|v| by_id.get(v)) else {
332                log::info(&format!(
333                    "  {key} ({}): not found in workspace voices — skipping",
334                    py_str(&vid)
335                ));
336                continue;
337            };
338            if m.get("role") == Some(&Value::from("TBD"))
339                && rec.get("description").is_some_and(truthy)
340            {
341                let old = py_str(&m["role"]);
342                m.insert("role".into(), rec["description"].clone());
343                changes.push(format!(
344                    "  {key}.role: {} → {}",
345                    py_repr(&old),
346                    py_repr(&py_str(&m["role"]))
347                ));
348            }
349            if !m.get("language_code").is_some_and(truthy)
350                && rec.get("language").is_some_and(truthy)
351            {
352                m.insert("language_code".into(), rec["language"].clone());
353                changes.push(format!(
354                    "  {key}.language_code: null → {}",
355                    py_repr(&py_str(&m["language_code"]))
356                ));
357            }
358        }
359    }
360    if changes.is_empty() {
361        log::info("  No updates needed — cast file is already fully populated.");
362        return Ok(());
363    }
364    log::info(&format!(
365        "  {}Changes ({}):",
366        if dry_run { "(dry run) " } else { "" },
367        changes.len()
368    ));
369    for c in &changes {
370        log::info(c);
371    }
372    if !dry_run {
373        fs::write(path, dumps(&cast, Style::INDENT2_UTF8) + "\n")?;
374        log::info(&format!("  Written: {path}"));
375    }
376    Ok(())
377}
378
379pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
380    log::init("voices");
381    let _banner = banner::begin(super::prog(), &super::argv_line(args));
382    let a: Args = match super::parse_or_exit("xil-voices", args) {
383        Ok(a) => a,
384        Err(code) => return Ok(code),
385    };
386    let client = xil_api::elevenlabs::Client::from_env();
387    let response = client
388        .voices_get_all()
389        .map_err(|e| anyhow::anyhow!("elevenlabs.core.api_error.ApiError: {e}"))?;
390    let mut records: Vec<Map<String, Value>> = response["voices"]
391        .as_array()
392        .into_iter()
393        .flatten()
394        .map(build_voice_record)
395        .collect();
396    let by_id: IndexMap<String, Map<String, Value>> = records
397        .iter()
398        .map(|r| (py_str(&r["voice_id"]), r.clone()))
399        .collect();
400
401    if let Some(cast) = a.update_cast.as_ref().filter(|c| !c.is_empty()) {
402        log::info(&format!("\n--- Updating cast file: {cast} ---"));
403        update_cast(cast, &by_id, a.dry_run)?;
404        return Ok(0);
405    }
406    if let Some(id) = a.id.as_ref().filter(|i| !i.is_empty()) {
407        match records.iter().find(|r| r["voice_id"].as_str() == Some(id)) {
408            Some(r) => print_verbose(r),
409            None => log::info(&format!(
410                "Voice ID {} not found in your workspace.",
411                py_repr(id)
412            )),
413        }
414        return Ok(0);
415    }
416    if let Some(cats) = a.category.as_ref().filter(|c| !c.is_empty()) {
417        let cats: Vec<String> = cats.iter().map(|c| c.to_lowercase()).collect();
418        records.retain(|r| cats.contains(&s(&r["category"]).to_lowercase()));
419    }
420    if let Some(q) = a.search.as_ref().filter(|q| !q.is_empty()) {
421        let q = q.to_lowercase();
422        records.retain(|r| {
423            ["name", "description", "library_name"]
424                .iter()
425                .any(|k| s(&r[*k]).to_lowercase().contains(&q))
426        });
427    }
428    records.sort_by(|x, y| {
429        let kx = (!truthy(&x["is_bookmarked"]), s(&x["name"]).to_lowercase());
430        let ky = (!truthy(&y["is_bookmarked"]), s(&y["name"]).to_lowercase());
431        kx.cmp(&ky)
432    });
433
434    if a.json {
435        let arr = Value::Array(records.into_iter().map(Value::Object).collect());
436        println!("{}", dumps(&arr, Style::INDENT2));
437        return Ok(0);
438    }
439
440    let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
441    for r in &records {
442        let c = if truthy(&r["category"]) {
443            py_str(&r["category"])
444        } else {
445            "unknown".into()
446        };
447        *counts.entry(c).or_default() += 1;
448    }
449    log::info(&format!(
450        "\n--- ElevenLabs Voices ({} shown) ---",
451        records.len()
452    ));
453    for (cat, n) in &counts {
454        log::info(&format!(
455            "  {}: {n}",
456            category_label(cat)
457                .map(str::to_string)
458                .unwrap_or_else(|| cat.clone())
459        ));
460    }
461    log::info("");
462    if a.verbose {
463        records.iter().for_each(print_verbose);
464    } else {
465        records.iter().for_each(print_compact);
466        log::info("");
467        log::info("  Use --verbose for full details, --json for machine-readable output,");
468        log::info("  --id <VOICE_ID> for a single voice, --category / --search to filter.");
469    }
470    Ok(0)
471}
472
473/// The clap definition behind `--help`, for man pages.
474pub fn command() -> clap::Command {
475    <Args as clap::CommandFactory>::command()
476}