1use 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 #[arg(long, value_name = "TAG")]
26 episode: Option<String>,
27 #[arg(long, value_name = "PATH")]
29 cast: Option<String>,
30 #[arg(long)]
32 show: Option<String>,
33 #[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 #[arg(long, value_name = "PATH")]
38 chatterbox_python: Option<String>,
39 #[arg(long, default_value = "voice_refs", value_name = "DIR")]
41 voice_refs: String,
42 #[arg(long, default_value = "cuda", value_parser = ["cuda", "cpu"], value_name = "DEVICE")]
44 device: String,
45 #[arg(long, value_name = "TEXT")]
47 sample_text: Option<String>,
48 #[arg(long)]
50 dry_run: bool,
51 #[arg(long)]
53 force: bool,
54}
55
56fn 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
84fn 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 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 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
368pub 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}