1use std::collections::BTreeSet;
7use std::ffi::OsString;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use crate::cmd::py_str;
12use anyhow::bail;
13use clap::Parser;
14use indexmap::IndexMap;
15use serde_json::{Map, Value};
16use xil_audio::fx::py_repr;
17use xil_core::fsutil::basename;
18use xil_core::pyfmt::{commas, pad_right, round_to};
19use xil_core::pyjson::{dumps, float_repr, py_float, Style};
20use xil_core::workspace::{derive_paths, resolve_slug, resolve_venv_python, workspace_root};
21use xil_core::{banner, log};
22
23use crate::mix::config::{CastConfig, Num, SfxConfig};
24use crate::sfxgen;
25use crate::tts::{self, Flavor};
26
27#[derive(Parser)]
28#[command(
29 name = "xil-produce",
30 about = "Voice Generation — generate voice stems via ElevenLabs"
31)]
32#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
33struct Args {
34 #[arg(long)]
36 episode: Option<String>,
37 #[arg(long)]
39 tag: Option<String>,
40 #[arg(long)]
42 show: Option<String>,
43 #[arg(long)]
45 script: Option<String>,
46 #[arg(long)]
48 dry_run: bool,
49 #[arg(long)]
51 reconcile: bool,
52 #[arg(long)]
54 apply: bool,
55 #[arg(long)]
57 force: bool,
58 #[arg(long, default_value_t = 1, allow_negative_numbers = true)]
60 start_from: i64,
61 #[arg(long, allow_negative_numbers = true)]
63 stop_at: Option<i64>,
64 #[arg(long, value_name = "N,N,...", value_parser = parse_seq_list, allow_hyphen_values = true)]
66 seq_list: Option<BTreeSet<i64>>,
67 #[arg(long)]
69 terse: bool,
70 #[arg(long)]
72 gen_sfx: bool,
73 #[arg(long)]
75 gen_music: bool,
76 #[arg(long)]
78 gen_ambience: bool,
79 #[arg(long)]
81 sfx_music: bool,
82 #[arg(long)]
84 local_only: bool,
85 #[arg(long, default_value = "elevenlabs", value_parser = ["elevenlabs", "gtts", "chatterbox", "chatterbox-turbo"], value_name = "BACKEND")]
87 backend: String,
88 #[arg(long, value_name = "PATH")]
90 chatterbox_python: Option<String>,
91 #[arg(long, value_name = "DIR")]
93 voice_refs: Option<String>,
94 #[arg(long, default_value = "cuda", value_parser = ["cuda", "cpu"], value_name = "DEVICE")]
96 device: String,
97 #[arg(long, default_value = "elevenlabs", value_parser = ["elevenlabs", "mmaudio"], value_name = "BACKEND")]
99 sfx_backend: String,
100 #[arg(long, value_name = "PATH")]
102 mmaudio_python: Option<String>,
103 #[arg(
105 long,
106 default_value_t = 8.0,
107 value_name = "FLOAT",
108 allow_negative_numbers = true
109 )]
110 mmaudio_duration: f64,
111 #[arg(
113 long,
114 default_value_t = 4.5,
115 value_name = "FLOAT",
116 allow_negative_numbers = true
117 )]
118 mmaudio_cfg: f64,
119 #[arg(
121 long,
122 default_value_t = 25,
123 value_name = "INT",
124 allow_negative_numbers = true
125 )]
126 mmaudio_steps: i64,
127 #[arg(long, default_value = "", value_name = "STR")]
129 mmaudio_negative_prompt: String,
130 #[arg(long, value_name = "INT", allow_negative_numbers = true)]
132 mmaudio_seed: Option<i64>,
133 #[arg(long)]
135 mmaudio_accept_noncommercial: bool,
136}
137
138fn parse_seq_list(s: &str) -> Result<BTreeSet<i64>, String> {
140 let tokens: Vec<&str> = s
141 .split(',')
142 .map(str::trim)
143 .filter(|t| !t.is_empty())
144 .collect();
145 if tokens.is_empty() {
146 return Err(format!("no sequence numbers found in {}", py_repr(s)));
147 }
148 tokens
149 .iter()
150 .map(|t| {
151 t.replace('_', "")
152 .parse::<i64>()
153 .map_err(|_| format!("invalid sequence number list: {}", py_repr(s)))
154 })
155 .collect()
156}
157
158struct SpeakerCfg {
160 id: String,
161 full_name: String,
162 stability: Option<f64>,
163 similarity_boost: Option<f64>,
164 style: Option<f64>,
165 use_speaker_boost: Option<bool>,
166 language_code: Option<String>,
167 speed: Option<f64>,
168}
169
170struct Dialogue {
171 speaker: String,
172 text: String,
173 stem_name: String,
174 seq: i64,
175 section: Option<String>,
176 direction: Option<String>,
177}
178
179fn load_production(
181 script: &Path,
182 cast_path: &Path,
183) -> anyhow::Result<(IndexMap<String, SpeakerCfg>, Vec<Dialogue>, String)> {
184 if !cast_path.exists() {
185 bail!(
186 "FileNotFoundError: Cast config not found: {}\nRun XILP001 first or check your --episode flag.",
187 cast_path.display()
188 );
189 }
190 if !script.exists() {
191 bail!(
192 "FileNotFoundError: Parsed script not found: {}\nRun XILP001 first or check your --script flag.",
193 script.display()
194 );
195 }
196 let data: Value = serde_json::from_str(&fs::read_to_string(script)?)?;
197 let cast = CastConfig::load(cast_path)?;
198 let mut config = IndexMap::new();
199 for (key, v) in &cast.cast {
200 let f = |k: &str| v.raw.get(k).and_then(Num::from_value).map(Num::f);
201 config.insert(
202 key.clone(),
203 SpeakerCfg {
204 id: v.voice_id.clone(),
205 full_name: v.full_name.clone(),
206 stability: f("stability"),
207 similarity_boost: f("similarity_boost"),
208 style: f("style"),
209 use_speaker_boost: v.raw.get("use_speaker_boost").and_then(Value::as_bool),
210 language_code: v
211 .raw
212 .get("language_code")
213 .and_then(Value::as_str)
214 .map(str::to_string),
215 speed: f("speed"),
216 },
217 );
218 }
219 let mut dialogue = Vec::new();
220 for e in data["entries"].as_array().into_iter().flatten() {
221 if e.get("type").and_then(Value::as_str) != Some("dialogue") {
222 continue;
223 }
224 let seq = e["seq"].as_i64().unwrap_or(0);
225 let section = e.get("section").cloned().unwrap_or(Value::Null);
226 let mut stem = format!("{seq:03}_{}", py_str(§ion));
227 if let Some(scene) = e
228 .get("scene")
229 .and_then(Value::as_str)
230 .filter(|s| !s.is_empty())
231 {
232 stem.push('-');
233 stem.push_str(scene);
234 }
235 let speaker = e.get("speaker").and_then(Value::as_str).ok_or_else(|| {
236 anyhow::anyhow!(
237 "1 validation error for DialogueEntry\nspeaker\n Input should be a valid string"
238 )
239 })?;
240 stem.push('_');
241 stem.push_str(speaker);
242 dialogue.push(Dialogue {
243 speaker: speaker.to_string(),
244 text: e
245 .get("text")
246 .and_then(Value::as_str)
247 .unwrap_or("")
248 .to_string(),
249 stem_name: stem,
250 seq,
251 section: section.as_str().map(str::to_string),
252 direction: e
253 .get("direction")
254 .and_then(Value::as_str)
255 .map(str::to_string),
256 });
257 }
258 Ok((config, dialogue, cast.tag))
259}
260
261fn in_selection(seq: i64, start: i64, stop: Option<i64>, list: Option<&BTreeSet<i64>>) -> bool {
262 match list {
263 Some(l) => l.contains(&seq),
264 None => seq >= start && stop.map_or(true, |s| seq <= s),
265 }
266}
267
268fn format_seq_list(list: &BTreeSet<i64>) -> String {
269 let v: Vec<String> = list.iter().map(|s| s.to_string()).collect();
270 if v.len() <= 10 {
271 v.join(",")
272 } else {
273 format!("{},... and {} more", v[..10].join(","), v.len() - 10)
274 }
275}
276
277fn subscription(
280 client: &xil_api::elevenlabs::Client,
281) -> anyhow::Result<Option<(i64, i64, String)>> {
282 match client.user_get() {
283 Ok(u) => {
284 let s = &u["subscription"];
285 Ok(Some((
286 s["character_count"].as_i64().unwrap_or(0),
287 s["character_limit"].as_i64().unwrap_or(0),
288 s["tier"].as_str().unwrap_or("").to_string(),
289 )))
290 }
291 Err(xil_api::ApiError::Status { status, body }) => {
292 log::debug(&format!("user.get failed: {status} {body}"));
293 Ok(None)
294 }
295 Err(e) => bail!("httpx.TransportError: {e}"),
296 }
297}
298
299fn check_quota(client: &xil_api::elevenlabs::Client) -> anyhow::Result<()> {
300 match client.user_get() {
301 Ok(u) => {
302 let s = &u["subscription"];
303 let (used, limit) = (
304 s["character_count"].as_i64().unwrap_or(0),
305 s["character_limit"].as_i64().unwrap_or(0),
306 );
307 log::info(&format!("\n{}", "=".repeat(40)));
308 log::info("ELEVENLABS API STATUS:");
309 log::info(&format!(
310 " Tier: {}",
311 s["tier"].as_str().unwrap_or("").to_uppercase()
312 ));
313 log::info(&format!(
314 " Usage: {} / {} characters",
315 commas(used),
316 commas(limit)
317 ));
318 log::info(&format!(" Remaining: {}", commas(limit - used)));
319 log::info(&format!("{}\n", "=".repeat(40)));
320 }
321 Err(xil_api::ApiError::Status { status, body }) => {
322 log::warning("API Error: Unable to fetch user subscription data.");
323 log::warning(&format!(" Details: status_code: {status}, body: {body}"));
324 }
325 Err(e) => bail!("httpx.TransportError: {e}"),
326 }
327 Ok(())
328}
329
330fn best_model(client: &xil_api::elevenlabs::Client) -> anyhow::Result<String> {
331 match subscription(client)? {
332 Some((used, limit, _)) => {
333 let remaining = limit - used;
334 if remaining > 5000 {
335 log::info(&format!(
336 " [Budget] Healthy Balance: {} left. Using 'eleven_v3'.",
337 commas(remaining)
338 ));
339 } else {
340 log::warning(&format!(
341 " [Budget] LOW BALANCE: {} left. Continuing with 'eleven_v3' — audio tags like [pause] require v3 and cannot fall back to flash.",
342 commas(remaining)
343 ));
344 }
345 }
346 None => log::info(" [Budget] API Check Failed. Defaulting to 'eleven_v3'."),
347 }
348 Ok("eleven_v3".into())
349}
350
351fn has_enough(client: &xil_api::elevenlabs::Client, text: &str) -> anyhow::Result<bool> {
352 let required = text.chars().count() as i64;
353 match subscription(client)? {
354 Some((used, limit, _)) => {
355 let remaining = limit - used;
356 if remaining >= required {
357 log::info(&format!(
358 " [Guard] Quota OK: {required} required, {} left.",
359 commas(remaining)
360 ));
361 Ok(true)
362 } else {
363 log::info(&format!(
364 " [Guard] STOP: Line requires {required} chars, but only {} remain.",
365 commas(remaining)
366 ));
367 Ok(false)
368 }
369 }
370 None => {
371 log::warning(" [Guard] Permission 'user_read' missing. Skipping quota check.");
372 Ok(true)
373 }
374 }
375}
376
377type Key = (String, String, u64, u64, u64, String);
380
381fn content_key(
382 text: &str,
383 voice_id: &str,
384 speed: Option<f64>,
385 stability: Option<f64>,
386 similarity: Option<f64>,
387 backend: &str,
388) -> Key {
389 let or = |v: Option<f64>, d: f64| round_to(v.filter(|x| *x != 0.0).unwrap_or(d), 4).to_bits();
390 (
391 text.to_string(),
392 voice_id.to_string(),
393 or(speed, 1.0),
394 or(stability, 0.5),
395 or(similarity, 0.75),
396 backend.to_string(),
397 )
398}
399
400fn entry_key(m: &Map<String, Value>) -> Key {
401 let s = |k: &str| m.get(k).and_then(Value::as_str).unwrap_or("").to_string();
402 let f = |k: &str| m.get(k).and_then(Value::as_f64);
403 content_key(
404 &s("text"),
405 &s("voice_id"),
406 f("speed"),
407 f("stability"),
408 f("similarity_boost"),
409 &s("backend"),
410 )
411}
412
413fn manifest_path(stems_dir: &Path) -> PathBuf {
414 stems_dir.join(format!("{}_stem_manifest.json", basename(stems_dir)))
415}
416
417fn load_manifest(path: &Path) -> Value {
418 fs::read_to_string(path)
419 .ok()
420 .and_then(|t| serde_json::from_str(&t).ok())
421 .unwrap_or_else(|| serde_json::json!({"version": 1, "entries": []}))
422}
423
424fn save_manifest(path: &Path, manifest: &Value) -> std::io::Result<()> {
425 let tmp = PathBuf::from(format!("{}.tmp", path.display()));
426 fs::write(&tmp, dumps(manifest, Style::INDENT2))?;
427 fs::rename(&tmp, path)
428}
429
430fn has_key(manifest: &Value, key: &Key) -> bool {
431 manifest["entries"]
432 .as_array()
433 .into_iter()
434 .flatten()
435 .filter_map(Value::as_object)
436 .any(|m| &entry_key(m) == key)
437}
438
439fn upsert(manifest: &mut Value, entry: Map<String, Value>) {
440 let key = entry_key(&entry);
441 let list = manifest
442 .get_mut("entries")
443 .and_then(Value::as_array_mut)
444 .expect("manifest entries");
445 for existing in list.iter_mut().rev() {
446 if let Some(o) = existing.as_object_mut() {
447 if entry_key(o) == key {
448 for (k, v) in entry {
449 o.insert(k, v);
450 }
451 return;
452 }
453 }
454 }
455 list.push(Value::Object(entry));
456}
457
458fn opt_f(v: Option<f64>, default: Option<f64>) -> Value {
459 let _ = default;
461 v.map(py_float).unwrap_or(Value::Null)
462}
463
464fn manifest_entry(
465 d: &Dialogue,
466 cfg: Option<&SpeakerCfg>,
467 backend: &str,
468 model: &str,
469 sha: String,
470 stem: &Path,
471 generated_at: String,
472) -> Map<String, Value> {
473 let mut m = Map::new();
474 m.insert("text".into(), d.text.clone().into());
475 m.insert("speaker".into(), d.speaker.clone().into());
476 match cfg {
477 Some(c) => {
478 m.insert("voice_id".into(), c.id.clone().into());
479 m.insert("speed".into(), opt_f(c.speed, Some(1.0)));
480 m.insert("stability".into(), opt_f(c.stability, Some(0.5)));
481 m.insert(
482 "similarity_boost".into(),
483 opt_f(c.similarity_boost, Some(0.75)),
484 );
485 }
486 None => {
487 m.insert("voice_id".into(), "".into());
488 m.insert("speed".into(), py_float(1.0));
489 m.insert("stability".into(), py_float(0.5));
490 m.insert("similarity_boost".into(), py_float(0.75));
491 }
492 }
493 m.insert("backend".into(), backend.into());
494 m.insert("model".into(), model.into());
495 m.insert("sha256".into(), sha.into());
496 m.insert("seq_at_generation".into(), d.seq.into());
497 m.insert("stem_filename".into(), basename(stem).into());
498 m.insert("generated_at".into(), generated_at.into());
499 m
500}
501
502fn cfg_key(d: &Dialogue, cfg: Option<&SpeakerCfg>, backend: &str) -> Key {
503 match cfg {
504 Some(c) => content_key(
505 &d.text,
506 &c.id,
507 c.speed,
508 c.stability,
509 c.similarity_boost,
510 backend,
511 ),
512 None => content_key(&d.text, "", Some(1.0), Some(0.5), Some(0.75), backend),
513 }
514}
515
516struct DryRunOpts<'a> {
519 start: i64,
520 stop: Option<i64>,
521 list: Option<&'a BTreeSet<i64>>,
522 stems_dir: &'a Path,
523 force: bool,
524 backend: &'a str,
525}
526
527fn dry_run(
528 config: &IndexMap<String, SpeakerCfg>,
529 dialogue: &[Dialogue],
530 o: &DryRunOpts,
531 sfx: Option<(&[sfxgen::PlanEntry], &SfxConfig, &Path, &str)>,
532) {
533 let bar = "=".repeat(70);
534 log::info(&format!("\n{bar}"));
535 log::info(&format!("DRY RUN — {} dialogue lines", dialogue.len()));
536 log::info(&bar);
537 log::info(&format!(
538 " [.] {:<3} | {:<14} | {:>10} | voice check [lang]",
539 "seq", "speaker", "chars"
540 ));
541 log::info(&format!(" {}", "-".repeat(67)));
542 let mut total_chars = 0i64;
543 let mut to_generate = 0;
544 let mut gen: IndexMap<String, (i64, i64)> = IndexMap::new();
545 let mut skip: IndexMap<String, (i64, i64)> = IndexMap::new();
546 for e in dialogue {
547 let chars = e.text.chars().count() as i64;
548 total_chars += chars;
549 let in_range = in_selection(e.seq, o.start, o.stop, o.list);
550 let exists = !o.force && o.stems_dir.join(format!("{}.mp3", e.stem_name)).exists();
551 let marker = if exists {
552 let b = skip.entry(e.speaker.clone()).or_default();
553 b.0 += 1;
554 b.1 += chars;
555 "="
556 } else if in_range {
557 let b = gen.entry(e.speaker.clone()).or_default();
558 b.0 += 1;
559 b.1 += chars;
560 to_generate += 1;
561 " "
562 } else {
563 "x"
564 };
565 let direction = e
566 .direction
567 .as_ref()
568 .filter(|d| !d.is_empty())
569 .map(|d| format!(" ({d})"))
570 .unwrap_or_default();
571 let preview = if e.text.chars().count() > 75 {
572 format!("{}...", e.text.chars().take(75).collect::<String>())
573 } else {
574 e.text.clone()
575 };
576 let cfg = config.get(&e.speaker);
577 let voice_id = cfg.map(|c| c.id.as_str()).unwrap_or("???");
578 let status = if voice_id == "TBD" && o.backend == "elevenlabs" {
579 "TBD"
580 } else {
581 "OK"
582 };
583 let mut parts = Vec::new();
584 if let Some(c) = cfg {
585 for (k, v) in [
586 ("stability", c.stability),
587 ("similarity_boost", c.similarity_boost),
588 ("style", c.style),
589 ] {
590 if let Some(x) = v {
591 parts.push(format!("{k}={}", float_repr(x)));
592 }
593 }
594 if c.use_speaker_boost == Some(true) {
595 parts.push("speaker_boost".into());
596 }
597 if let Some(l) = c.language_code.as_ref().filter(|l| !l.is_empty()) {
598 parts.push(format!("lang={l}"));
599 }
600 }
601 let vs_note = if parts.is_empty() {
602 String::new()
603 } else {
604 format!(" [{}]", parts.join(", "))
605 };
606 log::info(&format!(
607 " [{marker}] {:03} | {} | {chars:4} chars | voice: {status}{vs_note}{direction}",
608 e.seq,
609 pad_right(&e.speaker, 14)
610 ));
611 log::info(&format!(" {preview}"));
612 log::info(&format!(" stem: {}.mp3", e.stem_name));
613 log::info("");
614 }
615 if let Some((entries, cfg, stems_dir, backend)) = sfx {
616 sfxgen::dry_run_sfx(entries, cfg, o.stems_dir, stems_dir, backend);
617 }
618 let chars_in_range: i64 = dialogue
619 .iter()
620 .filter(|e| in_selection(e.seq, o.start, o.stop, o.list))
621 .map(|e| e.text.chars().count() as i64)
622 .sum();
623 let tbd: Vec<&str> = if o.backend == "elevenlabs" {
624 config
625 .iter()
626 .filter(|(_, c)| c.id == "TBD")
627 .map(|(k, _)| k.as_str())
628 .collect()
629 } else {
630 Vec::new()
631 };
632 log::info(&bar);
633 log::info(&format!(
634 "TOTAL: {} lines, {} TTS characters",
635 dialogue.len(),
636 commas(total_chars)
637 ));
638 let range_label = if let Some(l) = o.list {
639 Some(format!("SEQ-LIST {}", format_seq_list(l)))
640 } else if o.start > 1 || o.stop.is_some() {
641 Some(match (o.start > 1, o.stop) {
642 (true, Some(s)) => format!("FROM {}–{s}", o.start),
643 (false, Some(s)) => format!("THRU {s}"),
644 _ => format!("FROM {}", o.start),
645 })
646 } else {
647 None
648 };
649 if let Some(label) = range_label {
650 log::info(&format!(
651 "{label}: {to_generate} lines, {} TTS characters",
652 commas(chars_in_range)
653 ));
654 }
655 if !tbd.is_empty() {
656 log::warning(&format!(
657 "\n {} voices still need voice_id assignment: {}",
658 tbd.len(),
659 tbd.join(", ")
660 ));
661 log::info(
662 " Use XILU001_discover_voices_T2S.py to browse voices, then update the cast config",
663 );
664 }
665 log::info(&format!("{bar}\n"));
666
667 if !gen.is_empty() {
668 let mut rows: Vec<&String> = gen.keys().collect();
669 rows.sort_by(|a, b| gen[*b].1.cmp(&gen[*a].1));
670 let mut skip_only: Vec<&String> = skip.keys().filter(|s| !gen.contains_key(*s)).collect();
671 skip_only.sort_by(|a, b| skip[*b].1.cmp(&skip[*a].1));
672 rows.extend(skip_only);
673 let sep = format!(
674 "{} {} {} {} {}",
675 "-".repeat(16),
676 "-".repeat(5),
677 "-".repeat(8),
678 "-".repeat(5),
679 "-".repeat(8)
680 );
681 log::info("SPEAKER COST BREAKDOWN ([ ]=generate [=]=skip [x]=out of range)");
682 log::info(&format!(
683 "{:<16} {:>5} {:>8} {:>5} {:>8}",
684 "Speaker", "Lines", "Chars", "Lines", "Chars"
685 ));
686 log::info(&format!(
687 "{:<16} {:>5} {:>8} {:>5} {:>8}",
688 "", "gen", "gen", "skip", "skip"
689 ));
690 log::info(&sep);
691 for spk in rows {
692 let g = gen.get(spk).copied().unwrap_or_default();
693 let s = skip.get(spk).copied().unwrap_or_default();
694 log::info(&format!(
695 "{} {:>5} {:>8} {:>5} {:>8}",
696 pad_right(spk, 16),
697 g.0,
698 commas(g.1),
699 s.0,
700 commas(s.1)
701 ));
702 }
703 log::info(&sep);
704 let (gl, gc) = gen.values().fold((0, 0), |a, v| (a.0 + v.0, a.1 + v.1));
705 let (sl, sc) = skip.values().fold((0, 0), |a, v| (a.0 + v.0, a.1 + v.1));
706 log::info(&format!(
707 "{:<16} {gl:>5} {:>8} {sl:>5} {:>8}",
708 "TOTAL",
709 commas(gc),
710 commas(sc)
711 ));
712 log::info("");
713 }
714}
715
716fn print_voice_refs_table(config: &IndexMap<String, SpeakerCfg>, dir: &str) {
717 let mut speakers: Vec<&String> = config.keys().collect();
718 speakers.sort();
719 let has = |s: &str| {
720 Path::new(dir).join(format!("{s}.wav")).exists()
721 || Path::new(dir).join(format!("{s}.conds.pt")).exists()
722 };
723 let missing: Vec<&str> = speakers
724 .iter()
725 .filter(|s| !has(s))
726 .map(|s| s.as_str())
727 .collect();
728 log::info(&format!("VOICE REFS ({dir})"));
729 log::info(&format!(" {:<18} Ref", "Speaker"));
730 log::info(&format!(" {}", "-".repeat(30)));
731 for s in &speakers {
732 let mark = if has(s) {
733 "✓"
734 } else {
735 "✗ (fallback to default voice)"
736 };
737 log::info(&format!(" {} {mark}", pad_right(s, 18)));
738 }
739 log::info(&format!(" {}", "-".repeat(30)));
740 log::info(&format!(
741 " {} / {} have voice refs",
742 speakers.len() - missing.len(),
743 speakers.len()
744 ));
745 if !missing.is_empty() {
746 log::warning(&format!(" Missing refs: {}", missing.join(", ")));
747 log::warning(&format!(
748 " Add <key>.wav to {dir} to use a reference voice."
749 ));
750 }
751 log::info("");
752}
753
754fn reconcile(
755 config: &IndexMap<String, SpeakerCfg>,
756 dialogue: &[Dialogue],
757 stems_dir: &Path,
758 backend: &str,
759 apply: bool,
760) -> anyhow::Result<()> {
761 let mf = manifest_path(stems_dir);
762 if !mf.exists() {
763 log::error(&format!(
764 "No stem manifest at {} — run 'xil produce' first to build it.",
765 mf.display()
766 ));
767 return Ok(());
768 }
769 let mut manifest = load_manifest(&mf);
770 let entries: Vec<Map<String, Value>> = manifest["entries"]
771 .as_array()
772 .into_iter()
773 .flatten()
774 .filter_map(|v| v.as_object().cloned())
775 .collect();
776 let mut by_key: IndexMap<Key, usize> = IndexMap::new();
778 for (i, m) in entries.iter().enumerate() {
779 by_key.insert(entry_key(m), i);
780 }
781 let mut to_rename: Vec<(PathBuf, PathBuf, usize, &Dialogue)> = Vec::new();
782 let mut to_generate: Vec<(&Dialogue, String)> = Vec::new();
783 let mut correct = 0;
784 for d in dialogue {
785 let key = cfg_key(d, config.get(&d.speaker), backend);
786 let expected = stems_dir.join(format!("{}.mp3", d.stem_name));
787 if expected.exists() {
788 correct += 1;
789 continue;
790 }
791 let Some(&idx) = by_key.get(&key) else {
792 to_generate.push((d, "not in manifest".into()));
793 continue;
794 };
795 let fname = py_str(entries[idx].get("stem_filename").unwrap_or(&Value::Null));
796 let current = stems_dir.join(&fname);
797 if !current.exists() {
798 to_generate.push((d, format!("manifest file missing: {fname}")));
799 continue;
800 }
801 let actual = crate::cmd::mp3_hash::hash_file(¤t).ok();
802 if actual.as_deref() != entries[idx].get("sha256").and_then(Value::as_str) {
803 to_generate.push((d, format!("SHA-256 mismatch for {fname}")));
804 continue;
805 }
806 to_rename.push((current, expected, idx, d));
807 }
808 log::info(&format!(
809 "--- Reconcile: {correct} correct, {} to re-link, {} need new TTS ---",
810 to_rename.len(),
811 to_generate.len()
812 ));
813 for (src, dst, _, _) in &to_rename {
814 log::info(&format!(" RELINK {} → {}", basename(src), basename(dst)));
815 }
816 for (d, reason) in &to_generate {
817 log::info(&format!(
818 " MISSING seq {:03} {}: {reason}",
819 d.seq, d.speaker
820 ));
821 }
822 if !apply {
823 log::info(" (dry-run — pass --apply to execute renames)");
824 return Ok(());
825 }
826 for (src, dst, idx, d) in &to_rename {
827 fs::rename(src, dst)?;
828 let m = manifest["entries"][*idx].as_object_mut().expect("entry");
829 m.insert("stem_filename".into(), basename(dst).into());
830 m.insert("seq_at_generation".into(), d.seq.into());
831 log::info(&format!(" Relinked: {}", basename(dst)));
832 }
833 if !to_rename.is_empty() {
834 save_manifest(&mf, &manifest)?;
835 log::info(&format!(" Manifest updated: {}", basename(&mf)));
836 }
837 Ok(())
838}
839
840struct GenOpts<'a> {
843 start: i64,
844 stop: Option<i64>,
845 list: Option<&'a BTreeSet<i64>>,
846 show: &'a str,
847 backend: &'a str,
848 force: bool,
849 speed_overrides: &'a IndexMap<&'static str, f64>,
850}
851
852fn speakable(text: &str) -> bool {
854 static TAG: std::sync::LazyLock<regex::Regex> =
855 std::sync::LazyLock::new(|| regex::Regex::new(r"\[[^\]]*\]").unwrap());
856 static PUNCT: std::sync::LazyLock<regex::Regex> =
857 std::sync::LazyLock::new(|| regex::Regex::new(r"[^\w\s]").unwrap());
858 let s = TAG.replace_all(text, "");
859 !PUNCT.replace_all(&s, "").trim().is_empty()
860}
861
862fn generate_voices(
863 config: &IndexMap<String, SpeakerCfg>,
864 dialogue: &[Dialogue],
865 stems_dir: &Path,
866 o: &GenOpts,
867 client: &xil_api::elevenlabs::Client,
868 mut chatterbox: Option<&mut tts::Chatterbox>,
869) -> anyhow::Result<()> {
870 fs::create_dir_all(stems_dir)?;
871 let run_started_at = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
872 let mf = manifest_path(stems_dir);
873 let mut manifest = load_manifest(&mf);
874
875 if o.backend == "elevenlabs" {
876 let mut needed: Vec<&str> = dialogue
877 .iter()
878 .filter(|e| in_selection(e.seq, o.start, o.stop, o.list))
879 .map(|e| e.speaker.as_str())
880 .filter(|s| config.get(*s).is_some_and(|c| c.id == "TBD"))
881 .collect();
882 needed.sort();
883 needed.dedup();
884 if !needed.is_empty() {
885 log::error(&format!(
886 "Cannot generate: {} speaker(s) in range have no voice_id: {}\n Assign voice IDs in the cast config, then re-run.",
887 needed.len(),
888 needed.join(", ")
889 ));
890 return Ok(());
891 }
892 }
893 let todo: Vec<&Dialogue> = dialogue
894 .iter()
895 .filter(|e| in_selection(e.seq, o.start, o.stop, o.list))
896 .collect();
897 let range_note = if let Some(l) = o.list {
898 format!(" (seq list: {})", format_seq_list(l))
899 } else if let Some(s) = o.stop {
900 format!(" (seq {}–{s})", o.start)
901 } else if o.start > 1 {
902 format!(" (from seq {})", o.start)
903 } else {
904 String::new()
905 };
906 log::info(&format!(
907 "--- Phase 1: Generating {} voice stems{range_note} ---",
908 todo.len()
909 ));
910 let current_model = best_model(client)?;
911 let mut generated = 0;
912
913 for e in todo {
914 let tts_comment = if o.backend == "elevenlabs" {
915 current_model.clone()
916 } else {
917 o.backend.to_string()
918 };
919 let stem = stems_dir.join(format!("{}.mp3", e.stem_name));
920 let cfg = config.get(&e.speaker);
921 if fs::metadata(&stem).map(|m| m.len() > 0).unwrap_or(false) {
922 if !o.force {
923 log::info(&format!(" Exists: {} — skipping", stem.display()));
924 let key = cfg_key(e, cfg, o.backend);
925 if !has_key(&manifest, &key) {
926 if let Ok(sha) = crate::cmd::mp3_hash::hash_file(&stem) {
927 upsert(
928 &mut manifest,
929 manifest_entry(
930 e,
931 cfg,
932 o.backend,
933 &tts_comment,
934 sha,
935 &stem,
936 String::new(),
937 ),
938 );
939 }
940 }
941 continue;
942 }
943 log::warning(&format!(" Force: overwriting {}", basename(&stem)));
944 }
945 if o.backend == "elevenlabs" && cfg.is_some_and(|c| c.id == "TBD") {
946 log::warning(&format!(
947 "No voice_id for {} — skipping {}",
948 e.speaker, e.stem_name
949 ));
950 continue;
951 }
952 if o.backend == "elevenlabs" && !has_enough(client, &e.text)? {
953 log::info(&format!(
954 " !!! Production halted at seq {} to save credits.",
955 e.seq
956 ));
957 break;
958 }
959 if !speakable(&e.text) {
960 log::warning(&format!(
961 " SKIP seq {} ({}): text {} is empty after stripping speaker tags/emojis — convert this entry to a direction or replace the text in the script.",
962 e.seq,
963 e.speaker,
964 py_repr(&e.text)
965 ));
966 continue;
967 }
968 let override_speed = e
971 .section
972 .as_deref()
973 .and_then(|sec| o.speed_overrides.get(sec).copied());
974 let speed = override_speed.or_else(|| cfg.and_then(|c| c.speed));
975 let mut vs = Map::new();
976 if let Some(c) = cfg {
977 if let Some(x) = c.stability {
978 vs.insert("stability".into(), py_float(x));
979 }
980 if let Some(x) = c.similarity_boost {
981 vs.insert("similarity_boost".into(), py_float(x));
982 }
983 if let Some(x) = c.style {
984 vs.insert("style".into(), py_float(x));
985 }
986 if let Some(b) = c.use_speaker_boost {
987 vs.insert("use_speaker_boost".into(), b.into());
988 }
989 }
990 if let Some(x) = speed {
991 vs.insert("speed".into(), py_float(x));
992 }
993 let voice_settings: Option<Value> = (!vs.is_empty()).then_some(Value::Object(vs));
994
995 let tmp = tempfile::Builder::new()
996 .prefix("tmp")
997 .rand_bytes(8)
998 .suffix(".mp3.tmp")
999 .tempfile_in(stems_dir)?
1000 .into_temp_path();
1001 let rendered = (|| -> anyhow::Result<()> {
1002 let chars = e.text.chars().count();
1003 match o.backend {
1004 "gtts" => {
1005 log::info(&format!(
1006 " > [{:03}] {} via gTTS ({chars} chars)...",
1007 e.seq, e.speaker
1008 ));
1009 tts::gtts_generate(&e.text, &tmp, Flavor::Produce)?;
1010 }
1011 "chatterbox-turbo" => {
1012 log::info(&format!(
1013 " > [{:03}] {} via Chatterbox Turbo ({chars} chars)...",
1014 e.seq, e.speaker
1015 ));
1016 chatterbox
1017 .as_deref_mut()
1018 .expect("chatterbox client")
1019 .generate(&e.text, &tmp, &e.speaker)?;
1020 }
1021 _ => {
1022 log::info(&format!(
1023 " > [{:03}] {} with {current_model} ({chars} chars)...",
1024 e.seq, e.speaker
1025 ));
1026 let Some(c) = cfg else {
1027 bail!("KeyError: {}", py_repr(&e.speaker));
1028 };
1029 let audio = client
1030 .text_to_speech(
1031 &c.id,
1032 &e.text,
1033 ¤t_model,
1034 "mp3_44100_128",
1035 Some(voice_settings.clone().unwrap_or(Value::Null)),
1036 )
1037 .map_err(|err| {
1038 anyhow::anyhow!("elevenlabs.core.api_error.ApiError: {err}")
1039 })?;
1040 fs::write(&tmp, audio)?;
1041 }
1042 }
1043 if fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0) == 0 {
1044 bail!(
1045 "RuntimeError: TTS produced an empty file for seq {} ({}) via {}",
1046 e.seq,
1047 e.speaker,
1048 o.backend
1049 );
1050 }
1051 Ok(())
1052 })();
1053 if let Err(err) = rendered {
1054 let _ = fs::remove_file(&tmp);
1055 return Err(err);
1056 }
1057 fs::rename(&tmp, &stem)?;
1058 let _ = tmp.keep();
1059
1060 let full_name = cfg
1061 .map(|c| c.full_name.clone())
1062 .unwrap_or_else(|| super::parse::title_case(&e.speaker));
1063 let first_five: Vec<&str> = e.text.split_whitespace().take(5).collect();
1064 xil_audio::tags::tag_mp3_full(
1065 &stem,
1066 o.show,
1067 Some(&format!("{full_name}: {}", first_five.join(" "))),
1068 Some(&full_name),
1069 Some(&e.text),
1070 Some(&tts_comment),
1071 None,
1072 )?;
1073 log::info(&format!(" Saved: {}", stem.display()));
1074 if let Ok(sha) = crate::cmd::mp3_hash::hash_file(&stem) {
1075 log::info(&format!(" SHA256: {sha}"));
1076 let now = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
1077 let mut entry = manifest_entry(e, cfg, o.backend, &tts_comment, sha, &stem, now);
1078 if let Some(sp) = override_speed {
1079 entry.insert("speed".into(), py_float(sp));
1080 if cfg.is_none() {
1081 entry.insert("stability".into(), Value::Null);
1082 entry.insert("similarity_boost".into(), Value::Null);
1083 }
1084 }
1085 upsert(&mut manifest, entry);
1086 }
1087 generated += 1;
1088 }
1089 let stem_count = fs::read_dir(stems_dir)?
1090 .filter_map(Result::ok)
1091 .filter(|d| d.file_name().to_string_lossy().ends_with(".mp3"))
1092 .count();
1093 log::info(&format!(
1094 "--- Phase 1 Complete: {generated} new, {stem_count} total stems in {}/ ---",
1095 stems_dir.display()
1096 ));
1097 let n = manifest["entries"].as_array().map_or(0, Vec::len);
1098 match save_manifest(&mf, &manifest) {
1099 Ok(()) => {
1100 log::info(&format!(" Manifest: {} ({n} entries)", basename(&mf)));
1101 let snap = PathBuf::from(
1102 mf.to_string_lossy()
1103 .replace(".json", &format!("_{run_started_at}.json")),
1104 );
1105 match save_manifest(&snap, &manifest) {
1106 Ok(()) => log::info(&format!(" Snapshot: {}", basename(&snap))),
1107 Err(err) => log::warning(&format!("Could not write stem manifest: {err}")),
1108 }
1109 }
1110 Err(err) => log::warning(&format!("Could not write stem manifest: {err}")),
1111 }
1112 Ok(())
1113}
1114
1115fn sfx_dir_for(slug: &str) -> PathBuf {
1117 let per_show = workspace_root().join("SFX").join(slug);
1118 if per_show.is_dir() {
1119 per_show
1120 } else {
1121 workspace_root().join("SFX")
1122 }
1123}
1124
1125pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
1126 log::init("produce");
1127 let result = {
1128 let _banner = banner::begin(super::prog(), &super::argv_line(args));
1129 execute(args)
1130 };
1131 match result {
1132 Err(e)
1133 if e.downcast_ref::<super::SysExit>()
1134 .is_some_and(|s| s.0.is_empty()) =>
1135 {
1136 Ok(1)
1137 }
1138 other => super::finish(other),
1139 }
1140}
1141
1142fn execute(args: &[OsString]) -> anyhow::Result<i32> {
1143 let mut a: Args = match super::parse_or_exit("xil-produce", args) {
1144 Ok(a) => a,
1145 Err(code) => return Ok(code),
1146 };
1147 if a.backend == "chatterbox" {
1148 log::warning("--backend chatterbox was removed; using chatterbox-turbo instead. Stems will be recorded as chatterbox-turbo.");
1149 a.backend = "chatterbox-turbo".into();
1150 }
1151 let sfx_requested = a.gen_sfx || a.gen_music || a.gen_ambience || a.sfx_music;
1152 let needs_key = a.backend == "elevenlabs" || (a.sfx_backend == "elevenlabs" && sfx_requested);
1153 let key = std::env::var("ELEVENLABS_API_KEY")
1154 .ok()
1155 .filter(|k| !k.is_empty());
1156 if !a.dry_run && needs_key && key.is_none() {
1157 return Err(super::SysExit(
1158 "Error: ELEVENLABS_API_KEY environment variable is not set.".into(),
1159 )
1160 .into());
1161 }
1162 let arg_tag = a
1163 .episode
1164 .clone()
1165 .or_else(|| a.tag.clone())
1166 .unwrap_or_default();
1167 let slug = resolve_slug(a.show.as_deref(), "project.json");
1168 let paths = derive_paths(&slug, &arg_tag);
1169 let cast_path = paths["cast"].clone();
1170 let sfx_path = paths["sfx"].clone();
1171 if !cast_path.exists() {
1172 return Err(super::SysExit(format!(
1173 "Error: Cast config not found: {}\nRun XILP001 first or check your --episode flag.",
1174 cast_path.display()
1175 ))
1176 .into());
1177 }
1178 let cast_doc = CastConfig::load(&cast_path)?;
1179 let raw_cast: Value = serde_json::from_str(&fs::read_to_string(&cast_path)?)?;
1180 let script = a
1181 .script
1182 .clone()
1183 .map(PathBuf::from)
1184 .unwrap_or_else(|| paths["parsed"].clone());
1185
1186 let (config, mut dialogue, tag) = load_production(&script, &cast_path)?;
1187 let stems_dir = workspace_root().join("stems").join(&slug).join(&tag);
1188 if a.terse {
1189 for d in &mut dialogue {
1190 d.text = d
1191 .text
1192 .split_whitespace()
1193 .take(3)
1194 .collect::<Vec<_>>()
1195 .join(" ");
1196 }
1197 }
1198 let mut sfx_cfg: Option<SfxConfig> = None;
1199
1200 let gen_sfx = a.gen_sfx || a.sfx_music;
1201 let gen_music = a.gen_music || a.sfx_music;
1202 let gen_ambience = a.gen_ambience || a.sfx_music;
1203 let mut sfx_entries: Option<Vec<sfxgen::PlanEntry>> = None;
1204 if gen_sfx || gen_music || gen_ambience {
1205 let mut types = std::collections::HashSet::new();
1206 if gen_sfx {
1207 types.insert("SFX");
1208 types.insert("BEAT");
1209 }
1210 if gen_music {
1211 types.insert("MUSIC");
1212 }
1213 if gen_ambience {
1214 types.insert("AMBIENCE");
1215 types.insert("VINTAGE FILTER");
1216 }
1217 if !sfx_path.exists() {
1218 bail!(
1219 "FileNotFoundError: [Errno 2] No such file or directory: '{}'",
1220 sfx_path.display()
1221 );
1222 }
1223 let cfg = SfxConfig::load(&sfx_path)?;
1224 let mut entries = sfxgen::load_sfx_entries(
1225 &script,
1226 &cfg,
1227 None,
1228 Some(&types),
1229 a.local_only,
1230 &sfx_dir_for(&slug),
1231 )?;
1232 sfx_cfg = Some(cfg);
1233 if let Some(stop) = a.stop_at {
1234 entries.retain(|e| e.seq <= stop);
1235 }
1236 sfx_entries = Some(entries);
1237 }
1238
1239 let mut speed_overrides: IndexMap<&'static str, f64> = IndexMap::new();
1240 for section in ["preamble", "postamble"] {
1241 if let Some(s) = raw_cast
1242 .get(section)
1243 .and_then(|p| p.get("speed"))
1244 .and_then(Num::from_value)
1245 {
1246 speed_overrides.insert(section, s.f());
1247 }
1248 }
1249 let voice_refs = a.voice_refs.clone().unwrap_or_else(|| {
1250 workspace_root()
1251 .join("voice_refs")
1252 .to_string_lossy()
1253 .into_owned()
1254 });
1255 if a.backend == "chatterbox-turbo" {
1256 print_voice_refs_table(&config, &voice_refs);
1257 }
1258
1259 let client = xil_api::elevenlabs::Client::new(key);
1260 let sfx_live: Option<(&Vec<sfxgen::PlanEntry>, &SfxConfig)> = match (&sfx_entries, &sfx_cfg) {
1261 (Some(e), Some(c)) if !e.is_empty() => Some((e, c)),
1262 _ => None,
1263 };
1264
1265 if a.reconcile {
1266 return reconcile(&config, &dialogue, &stems_dir, &a.backend, a.apply).map(|_| 0);
1267 }
1268 if a.dry_run {
1269 let sfx_dir = sfx_dir_for(&slug);
1270 dry_run(
1271 &config,
1272 &dialogue,
1273 &DryRunOpts {
1274 start: a.start_from,
1275 stop: a.stop_at,
1276 list: a.seq_list.as_ref(),
1277 stems_dir: &stems_dir,
1278 force: a.force,
1279 backend: &a.backend,
1280 },
1281 sfx_live.map(|(e, c)| (e.as_slice(), c, sfx_dir.as_path(), a.sfx_backend.as_str())),
1282 );
1283 return Ok(0);
1284 }
1285
1286 if a.backend == "elevenlabs" {
1287 check_quota(&client)?;
1288 }
1289 if let Some((entries, cfg)) = sfx_live {
1290 let root = workspace_root();
1291 let missing: Vec<String> = entries
1292 .iter()
1293 .filter_map(|e| {
1294 let src = cfg.effects.get(&e.text)?.source.as_ref()?;
1295 let p = if Path::new(src).is_absolute() {
1296 PathBuf::from(src)
1297 } else {
1298 root.join(src)
1299 };
1300 (!p.exists()).then(|| format!(" '{}' → {src}", e.text))
1301 })
1302 .collect();
1303 if !missing.is_empty() {
1304 log::error(&format!(
1305 "{} SFX source file(s) declared but missing — fix sfx config before generating:",
1306 missing.len()
1307 ));
1308 for m in &missing {
1309 log::error(m);
1310 }
1311 return Ok(1);
1312 }
1313 }
1314 let mut chatterbox = None;
1315 if a.backend == "chatterbox-turbo" {
1316 let package_dir = crate::workers::python_package_dir();
1317 let Some(python) = resolve_venv_python(
1318 "venv-chatterbox",
1319 a.chatterbox_python.as_deref(),
1320 package_dir.as_deref(),
1321 ) else {
1322 log::error(
1323 "Cannot find chatterbox venv Python. Pass --chatterbox-python PATH, set XIL_CODEROOT to the directory containing venv-chatterbox/, or create venv-chatterbox/ in the workspace or repo root.",
1324 );
1325 return Ok(1);
1326 };
1327 chatterbox = Some(tts::Chatterbox::new(
1328 &python,
1329 &voice_refs,
1330 &a.device,
1331 Flavor::Produce,
1332 ));
1333 }
1334 let mut sfx_backend = None;
1335 if sfx_live.is_some() {
1336 let mm = sfxgen::MMAudioOptions {
1337 python: a.mmaudio_python.as_deref(),
1338 cfg: a.mmaudio_cfg,
1339 steps: a.mmaudio_steps,
1340 negative_prompt: &a.mmaudio_negative_prompt,
1341 seed: a.mmaudio_seed,
1342 duration: a.mmaudio_duration,
1343 accept_noncommercial: a.mmaudio_accept_noncommercial,
1344 };
1345 match sfxgen::make_sfx_backend(&a.sfx_backend, client.clone(), &mm)? {
1346 Some(b) => sfx_backend = Some(b),
1347 None => {
1348 if let Some(c) = chatterbox.as_mut() {
1349 c.close();
1350 }
1351 return Ok(1);
1352 }
1353 }
1354 }
1355 let opts = GenOpts {
1356 start: a.start_from,
1357 stop: a.stop_at,
1358 list: a.seq_list.as_ref(),
1359 show: &cast_doc.show,
1360 backend: &a.backend,
1361 force: a.force,
1362 speed_overrides: &speed_overrides,
1363 };
1364 let result = (|| -> anyhow::Result<()> {
1365 generate_voices(
1366 &config,
1367 &dialogue,
1368 &stems_dir,
1369 &opts,
1370 &client,
1371 chatterbox.as_mut(),
1372 )?;
1373 if let (Some((entries, cfg)), Some(backend)) = (sfx_live, sfx_backend.as_mut()) {
1374 sfxgen::generate_sfx(
1375 entries,
1376 cfg,
1377 &stems_dir,
1378 &sfx_dir_for(&slug),
1379 a.start_from,
1380 backend.as_mut(),
1381 )?;
1382 }
1383 Ok(())
1384 })();
1385 if let Some(c) = chatterbox.as_mut() {
1386 c.close();
1387 }
1388 if let Some(b) = sfx_backend.as_mut() {
1389 let _ = b.close();
1390 }
1391 result?;
1392 Ok(0)
1393}
1394
1395pub fn command() -> clap::Command {
1397 <Args as clap::CommandFactory>::command()
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403
1404 #[test]
1405 fn seq_lists_parse_like_argparse_type() {
1406 assert_eq!(
1407 parse_seq_list("12, 45, 88,").unwrap(),
1408 BTreeSet::from([12, 45, 88])
1409 );
1410 assert!(parse_seq_list(" , ").is_err());
1411 assert!(parse_seq_list("1,x").is_err());
1412 }
1413
1414 #[test]
1415 fn manifest_keys_default_missing_settings() {
1416 assert_eq!(
1417 content_key("a", "v", None, None, None, "b"),
1418 content_key("a", "v", Some(1.0), Some(0.5), Some(0.75), "b")
1419 );
1420 assert!(!speakable("[sigh] ... !!"));
1421 assert!(speakable("[sigh] okay"));
1422 }
1423}