Skip to main content

xil/cmd/
sample.rs

1//! `xil sample` — audition each cast voice with a short generated line.
2//! Port of `XILU004_sample_voices_T2S.py`.
3
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use anyhow::bail;
9use clap::Parser;
10use xil_core::pyfmt::{commas, pad_right};
11use xil_core::workspace::{derive_paths, resolve_slug, resolve_venv_python, workspace_root};
12use xil_core::{banner, log};
13
14use crate::mix::config::CastConfig;
15use crate::tts;
16
17#[derive(Parser)]
18#[command(
19    name = "xil-sample",
20    about = "Generate a voice sample MP3 for each cast member via the chosen TTS backend."
21)]
22#[command(group(clap::ArgGroup::new("target").required(true).args(["episode", "cast"])))]
23struct Args {
24    /// Episode tag (e.g. S02E03); derives cast config path
25    #[arg(long, value_name = "TAG")]
26    episode: Option<String>,
27    /// Explicit path to cast JSON file
28    #[arg(long, value_name = "PATH")]
29    cast: Option<String>,
30    /// Show name override (default: from project.json)
31    #[arg(long)]
32    show: Option<String>,
33    // Help text as an attribute: rustdoc would read its [tag] list as links.
34    #[arg(long, help = "TTS backend for sample generation. 'elevenlabs' (default) calls the ElevenLabs API and uses the voice_id from the cast config. 'gtts' generates a flat-voice draft via Google Translate TTS at no cost (ignores voice_id). 'chatterbox' uses local GPU TTS with zero-shot voice cloning from voice_refs/<key>.wav clips ('chatterbox' is a deprecated alias that warns and uses Turbo). 'chatterbox-turbo' uses the Chatterbox Turbo model in the same venv-chatterbox — it renders 19 native paralinguistic tags ([angry] [fear] [surprised] [happy] [crying] [sarcastic] [whispering] [dramatic] [narration] [advertisement] [laugh] [chuckle] [sigh] [gasp] [groan] [cough] [sniff] [shush] [clear throat]; exact spelling, no plurals), strips all other tags and needs reference clips >5s. Put a tag in --sample-text to audition a cue. Output lands in voice_samples/<TAG>/<backend>/ for side-by-side comparison", default_value = "elevenlabs", value_parser = ["elevenlabs", "gtts", "chatterbox", "chatterbox-turbo"])]
35    backend: String,
36    /// Path to the chatterbox venv Python (default: auto-detect ./venv-chatterbox/bin/python3). Used with --backend chatterbox or chatterbox-turbo.
37    #[arg(long, value_name = "PATH")]
38    chatterbox_python: Option<String>,
39    /// Directory containing <speaker_key>.wav reference clips for Chatterbox zero-shot voice cloning (default: voice_refs/). Used with --backend chatterbox or chatterbox-turbo.
40    #[arg(long, default_value = "voice_refs", value_name = "DIR")]
41    voice_refs: String,
42    /// Device for --backend chatterbox-turbo (default: cuda). The worker auto-falls back to cpu when cuda is requested but unavailable, so this rarely needs setting explicitly — pass 'cpu' to force it even when cuda would work. Slower on cpu, but functional.
43    #[arg(long, default_value = "cuda", value_parser = ["cuda", "cpu"], value_name = "DEVICE")]
44    device: String,
45    /// Override the sample text spoken by each voice. Use {name} as a placeholder for the speaker's full name. Default: "I am {name} not yo momma"
46    #[arg(long, value_name = "TEXT")]
47    sample_text: Option<String>,
48    /// Print what would be generated without calling any TTS API
49    #[arg(long)]
50    dry_run: bool,
51    /// Regenerate samples even if files already exist on disk
52    #[arg(long)]
53    force: bool,
54}
55
56/// `template.format(name=...)` — `{name}` and doubled braces only.
57fn format_name(template: &str, name: &str) -> anyhow::Result<String> {
58    let mut out = String::new();
59    let mut rest = template;
60    while !rest.is_empty() {
61        if let Some(r) = rest.strip_prefix("{{") {
62            out.push('{');
63            rest = r;
64        } else if let Some(r) = rest.strip_prefix("}}") {
65            out.push('}');
66            rest = r;
67        } else if let Some(r) = rest.strip_prefix("{name}") {
68            out.push_str(name);
69            rest = r;
70        } else if let Some(r) = rest.strip_prefix('{') {
71            let field: String = r.chars().take_while(|&c| c != '}').collect();
72            bail!("KeyError: '{field}'");
73        } else if rest.starts_with('}') {
74            bail!("ValueError: Single '}}' encountered in format string");
75        } else {
76            let c = rest.chars().next().unwrap();
77            out.push(c);
78            rest = &rest[c.len_utf8()..];
79        }
80    }
81    Ok(out)
82}
83
84/// `check_elevenlabs_quota()` in this module's wording.
85fn check_quota(client: &xil_api::elevenlabs::Client) -> anyhow::Result<()> {
86    match client.user_get() {
87        Ok(u) => {
88            let sub = &u["subscription"];
89            let (used, limit) = (
90                sub["character_count"].as_i64().unwrap_or(0),
91                sub["character_limit"].as_i64().unwrap_or(0),
92            );
93            log::info(&format!("\n{}", "=".repeat(40)));
94            log::info("ELEVENLABS API STATUS:");
95            log::info(&format!(
96                "  Tier:      {}",
97                sub["tier"].as_str().unwrap_or("").to_uppercase()
98            ));
99            log::info(&format!(
100                "  Usage:     {} / {} characters",
101                commas(used),
102                commas(limit)
103            ));
104            log::info(&format!("  Remaining: {}", commas(limit - used)));
105            log::info(&format!("{}\n", "=".repeat(40)));
106            Ok(())
107        }
108        Err(xil_api::ApiError::Status { status, body }) => {
109            log::warning("API Error: Unable to fetch user subscription data.");
110            log::warning(&format!("    Details: status_code: {status}, body: {body}"));
111            Ok(())
112        }
113        Err(e) => bail!("httpx.TransportError: {e}"),
114    }
115}
116
117fn remaining(client: &xil_api::elevenlabs::Client) -> Result<Option<i64>, anyhow::Error> {
118    match client.user_get() {
119        Ok(u) => {
120            let sub = &u["subscription"];
121            Ok(Some(
122                sub["character_limit"].as_i64().unwrap_or(0)
123                    - sub["character_count"].as_i64().unwrap_or(0),
124            ))
125        }
126        Err(xil_api::ApiError::Status { .. }) => Ok(None),
127        Err(e) => bail!("httpx.TransportError: {e}"),
128    }
129}
130
131pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
132    log::init("sample");
133    let result = {
134        let _banner = banner::begin(super::prog(), &super::argv_line(args));
135        execute(args)
136    };
137    super::finish(result)
138}
139
140fn execute(args: &[OsString]) -> anyhow::Result<i32> {
141    let a: Args = match super::parse_or_exit("xil-sample", args) {
142        Ok(a) => a,
143        Err(code) => return Ok(code),
144    };
145    let mut backend = a.backend.clone();
146    if backend == "chatterbox" {
147        log::warning("--backend chatterbox was removed; using chatterbox-turbo instead.");
148        backend = "chatterbox-turbo".into();
149    }
150    let key = std::env::var("ELEVENLABS_API_KEY")
151        .ok()
152        .filter(|k| !k.is_empty());
153    if !a.dry_run && backend == "elevenlabs" && key.is_none() {
154        return Err(super::SysExit(
155            "Error: ELEVENLABS_API_KEY environment variable is not set.".into(),
156        )
157        .into());
158    }
159    let cast_path: PathBuf = match a.cast.as_ref().filter(|c| !c.is_empty()) {
160        Some(c) => PathBuf::from(c),
161        None => {
162            let slug = resolve_slug(a.show.as_deref(), "project.json");
163            derive_paths(&slug, a.episode.as_deref().unwrap_or(""))["cast"].clone()
164        }
165    };
166    if !cast_path.exists() {
167        log::warning(&format!("Cast config not found: {}", cast_path.display()));
168        return Ok(1);
169    }
170    let cast = CastConfig::load(&cast_path)?;
171    let out_dir = workspace_root()
172        .join("voice_samples")
173        .join(&cast.tag)
174        .join(&backend);
175    log::info(&format!("Cast config : {}", cast_path.display()));
176    log::info(&format!("Episode tag : {}", cast.tag));
177    log::info(&format!("Backend     : {backend}"));
178    log::info(&format!("Output dir  : {}", out_dir.display()));
179    log::info(&format!("Cast members: {}", cast.cast.len()));
180    log::info("");
181
182    let client = xil_api::elevenlabs::Client::new(key);
183    if !a.dry_run {
184        if backend == "elevenlabs" {
185            check_quota(&client)?;
186        }
187        fs::create_dir_all(&out_dir)?;
188    }
189    let mut chatterbox = None;
190    if backend == "chatterbox-turbo" && !a.dry_run {
191        // Same search as `produce`; the error keeps Python's wording.
192        let package_dir = crate::workers::python_package_dir();
193        let python = resolve_venv_python(
194            "venv-chatterbox",
195            a.chatterbox_python.as_deref(),
196            package_dir.as_deref(),
197        )
198        .unwrap_or_else(|| {
199            Path::new("venv-chatterbox")
200                .join("bin")
201                .join("python3")
202                .to_string_lossy()
203                .into_owned()
204        });
205        if !Path::new(&python).exists() {
206            return Err(super::SysExit(format!(
207                "Error: Chatterbox Python not found at {python}. Use --chatterbox-python to specify the path."
208            ))
209            .into());
210        }
211        chatterbox = Some(tts::Chatterbox::new(
212            &python,
213            &a.voice_refs,
214            &a.device,
215            tts::Flavor::Sample,
216        ));
217    }
218
219    let (mut generated, mut skipped_tbd, mut skipped_exists) = (0, 0, 0);
220    let result = (|| -> anyhow::Result<()> {
221        for (k, member) in &cast.cast {
222            if backend == "elevenlabs" && member.voice_id == "TBD" {
223                log::info(&format!("  [ SKIP] {}  voice_id=TBD", pad_right(k, 12)));
224                skipped_tbd += 1;
225                continue;
226            }
227            let out_path = out_dir.join(format!("{k}.mp3"));
228            let template = a
229                .sample_text
230                .clone()
231                .filter(|t| !t.is_empty())
232                .unwrap_or_else(|| "I am {name} not yo momma".into());
233            let text = format_name(&template, &member.full_name)?;
234            if !a.force && out_path.exists() {
235                log::info(&format!(
236                    "  [EXISTS] {}  {}",
237                    pad_right(k, 12),
238                    out_path.display()
239                ));
240                skipped_exists += 1;
241                continue;
242            }
243            if a.dry_run {
244                let ref_note = if backend == "chatterbox-turbo" {
245                    let r = Path::new(&a.voice_refs).join(format!("{k}.wav"));
246                    format!(
247                        "  ref={}",
248                        if r.exists() {
249                            "✓"
250                        } else {
251                            "✗ (default voice)"
252                        }
253                    )
254                } else {
255                    String::new()
256                };
257                log::info(&format!(
258                    "  [DRY RUN] {}  ({})  →  {}{ref_note}",
259                    pad_right(k, 12),
260                    member.full_name,
261                    out_path.display()
262                ));
263                generated += 1;
264                continue;
265            }
266            if backend == "elevenlabs" {
267                let required = text.chars().count() as i64;
268                match remaining(&client)? {
269                    Some(left) if left >= required => log::info(&format!(
270                        " [Guard] Quota OK: {required} required, {} left.",
271                        commas(left)
272                    )),
273                    Some(left) => {
274                        log::info(&format!(
275                            " [Guard] STOP: Line requires {required} chars, but only {} remain.",
276                            commas(left)
277                        ));
278                        log::info(&format!(
279                            "  [ STOP] {}  insufficient quota",
280                            pad_right(k, 12)
281                        ));
282                        break;
283                    }
284                    None => log::info(
285                        " [Guard] Warning: Permission 'user_read' missing. Skipping quota check.",
286                    ),
287                }
288            }
289            log::info(&format!(
290                "  [   GEN] {}  {}  …",
291                pad_right(k, 12),
292                member.full_name
293            ));
294            let tts_comment = match backend.as_str() {
295                "gtts" => {
296                    tts::gtts_generate(&text, &out_path, tts::Flavor::Sample)?;
297                    "gtts".to_string()
298                }
299                "chatterbox-turbo" => {
300                    chatterbox
301                        .as_mut()
302                        .expect("started")
303                        .generate(&text, &out_path, k)?;
304                    backend.clone()
305                }
306                _ => {
307                    let model = match remaining(&client)? {
308                        Some(left) if left > 5000 => {
309                            log::info(&format!(
310                                " [Budget] Healthy Balance: {} left. Using 'eleven_v3'.",
311                                commas(left)
312                            ));
313                            "eleven_v3"
314                        }
315                        Some(left) => {
316                            log::info(&format!(
317                                " [Budget] LOW BALANCE: {} left. Switching to 'eleven_flash_v2_5'.",
318                                commas(left)
319                            ));
320                            "eleven_flash_v2_5"
321                        }
322                        None => {
323                            log::info(" [Budget] API Check Failed. Defaulting to 'eleven_v3'.");
324                            "eleven_v3"
325                        }
326                    };
327                    // The SDK's stream opens only once iteration starts, by
328                    // which point the output file already exists.
329                    fs::File::create(&out_path)?;
330                    let audio = client
331                        .text_to_speech(&member.voice_id, &text, model, "mp3_44100_128", None)
332                        .map_err(|e| anyhow::anyhow!("elevenlabs.core.api_error.ApiError: {e}"))?;
333                    fs::write(&out_path, audio)?;
334                    model.to_string()
335                }
336            };
337            xil_audio::tags::tag_mp3_full(
338                &out_path,
339                "Sample Show",
340                Some(&format!("Sample: {}", member.full_name)),
341                Some(&member.full_name),
342                Some(&text),
343                Some(&tts_comment),
344                None,
345            )?;
346            log::info(&format!("  saved → {}", out_path.display()));
347            generated += 1;
348        }
349        Ok(())
350    })();
351    if let Some(c) = chatterbox.as_mut() {
352        c.close();
353    }
354    result?;
355    log::info("");
356    if a.dry_run {
357        log::info(&format!(
358            "Dry run: {generated} would be generated, {skipped_tbd} TBD skipped."
359        ));
360    } else {
361        log::info(&format!(
362            "Done: {generated} generated, {skipped_exists} already existed, {skipped_tbd} TBD skipped."
363        ));
364    }
365    Ok(0)
366}
367
368/// The clap definition behind `--help`, for man pages.
369pub fn command() -> clap::Command {
370    <Args as clap::CommandFactory>::command()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn name_template() {
379        assert_eq!(format_name("I am {name}", "Nora").unwrap(), "I am Nora");
380        assert_eq!(
381            format_name("{{literal}} {name}", "X").unwrap(),
382            "{literal} X"
383        );
384        assert!(format_name("{other}", "X").is_err());
385    }
386}