Skip to main content

xil/cmd/
produce.rs

1//! `xil produce` — generate one voice stem per dialogue line (ElevenLabs,
2//! gTTS or Chatterbox Turbo), plus optional SFX stems, with a manifest that
3//! lets `--reconcile` re-link stems after a re-parse. Port of
4//! `XILP002_producer.py`.
5
6use 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    /// Episode tag (e.g. S01E01) — derives cast and SFX config paths
35    #[arg(long)]
36    episode: Option<String>,
37    /// Raw tag for non-episodic content (e.g. V01C03, D01)
38    #[arg(long)]
39    tag: Option<String>,
40    /// Show name override (default: from project.json)
41    #[arg(long)]
42    show: Option<String>,
43    /// Path to parsed script JSON (default: derived from cast config)
44    #[arg(long)]
45    script: Option<String>,
46    /// Preview all lines and TTS cost without API calls
47    #[arg(long)]
48    dry_run: bool,
49    /// Re-link existing stems to new seq filenames after a re-parse (reads stem manifest, proposes renames, no TTS calls). Add --apply to execute.
50    #[arg(long)]
51    reconcile: bool,
52    /// With --reconcile: execute the renames instead of dry-run preview.
53    #[arg(long)]
54    apply: bool,
55    /// Overwrite existing stem files instead of skipping them. Use with --start-from/--stop-at or --seq-list to regenerate specific lines. WARNING: incurs ElevenLabs API cost for every stem in range.
56    #[arg(long)]
57    force: bool,
58    /// Start generation from sequence number N (for resuming)
59    #[arg(long, default_value_t = 1, allow_negative_numbers = true)]
60    start_from: i64,
61    /// Stop generation at sequence number N, inclusive (for previewing a section)
62    #[arg(long, allow_negative_numbers = true)]
63    stop_at: Option<i64>,
64    /// Comma-separated list of exact sequence numbers to process (e.g. "12,45,88,203"), for regenerating a specific non-contiguous set of dialogue lines in one run — the TTS worker/subprocess is started once and reused across all listed seqs, instead of once per --start-from/--stop-at invocation. Takes precedence over --start-from/--stop-at when given. Dialogue only — does not affect --gen-sfx/--gen-music/--gen-ambience filtering. Combine with --force to actually overwrite stems that already exist on disk.
65    #[arg(long, value_name = "N,N,...", value_parser = parse_seq_list, allow_hyphen_values = true)]
66    seq_list: Option<BTreeSet<i64>>,
67    /// Truncate each line to 3 words to minimize TTS character cost
68    #[arg(long)]
69    terse: bool,
70    /// Generate SFX and BEAT stems
71    #[arg(long)]
72    gen_sfx: bool,
73    /// Generate music stems
74    #[arg(long)]
75    gen_music: bool,
76    /// Generate ambience stems
77    #[arg(long)]
78    gen_ambience: bool,
79    /// (deprecated) shorthand for --gen-sfx --gen-music --gen-ambience
80    #[arg(long)]
81    sfx_music: bool,
82    /// Only place stems for effects already present in SFX/; skip API generation
83    #[arg(long)]
84    local_only: bool,
85    /// TTS backend for dialogue voice stems.
86    #[arg(long, default_value = "elevenlabs", value_parser = ["elevenlabs", "gtts", "chatterbox", "chatterbox-turbo"], value_name = "BACKEND")]
87    backend: String,
88    /// Path to the Python executable in the chatterbox venv (default: auto-detect ./venv-chatterbox/bin/python3). Used with --backend chatterbox or chatterbox-turbo.
89    #[arg(long, value_name = "PATH")]
90    chatterbox_python: Option<String>,
91    /// Directory of <speaker_key>.wav reference clips for Chatterbox zero-shot voice cloning (default: <workspace>/voice_refs/). Missing refs fall back to Chatterbox's default voice.
92    #[arg(long, value_name = "DIR")]
93    voice_refs: Option<String>,
94    /// Device for --backend chatterbox-turbo (default: cuda).
95    #[arg(long, default_value = "cuda", value_parser = ["cuda", "cpu"], value_name = "DEVICE")]
96    device: String,
97    /// Backend for SFX/music/ambience generation, independent of the dialogue --backend.
98    #[arg(long, default_value = "elevenlabs", value_parser = ["elevenlabs", "mmaudio"], value_name = "BACKEND")]
99    sfx_backend: String,
100    /// Path to the Python executable in the MMAudio venv (default: auto-detect ./venv-mmaudio/bin/python3). Used only with --sfx-backend mmaudio.
101    #[arg(long, value_name = "PATH")]
102    mmaudio_python: Option<String>,
103    /// Generation length in seconds before trimming (default: 8.0, MMAudio's training duration).
104    #[arg(
105        long,
106        default_value_t = 8.0,
107        value_name = "FLOAT",
108        allow_negative_numbers = true
109    )]
110    mmaudio_duration: f64,
111    /// MMAudio classifier-free guidance strength (default: 4.5).
112    #[arg(
113        long,
114        default_value_t = 4.5,
115        value_name = "FLOAT",
116        allow_negative_numbers = true
117    )]
118    mmaudio_cfg: f64,
119    /// MMAudio flow-matching sampling steps (default: 25).
120    #[arg(
121        long,
122        default_value_t = 25,
123        value_name = "INT",
124        allow_negative_numbers = true
125    )]
126    mmaudio_steps: i64,
127    /// Optional MMAudio negative prompt (default: none).
128    #[arg(long, default_value = "", value_name = "STR")]
129    mmaudio_negative_prompt: String,
130    /// MMAudio reproducibility seed (default: nondeterministic).
131    #[arg(long, value_name = "INT", allow_negative_numbers = true)]
132    mmaudio_seed: Option<i64>,
133    /// Acknowledge that MMAudio's weights are CC BY-NC 4.0 (NON-COMMERCIAL USE ONLY) and that generated audio must not appear in a monetised production. Required for --sfx-backend mmaudio.
134    #[arg(long)]
135    mmaudio_accept_noncommercial: bool,
136}
137
138/// `_parse_seq_list`: comma-separated ints, blanks ignored.
139fn 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
158/// A speaker's config dict (`VoiceConfig` plus the voice-setting fields).
159struct 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
179/// `load_production(script, cast)` → `(config, dialogue, tag)`.
180fn 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(&section));
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
277// ── ElevenLabs quota helpers (the producer's wording) ───────────────────────
278
279fn 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
377// ── Stem manifest ───────────────────────────────────────────────────────────
378
379type 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    // `cfg.get("speed", 1.0)`: the key always exists, so a None stays None.
460    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
516// ── Dry run, voice refs, reconcile ──────────────────────────────────────────
517
518struct 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    // key → index of the last entry with that key (dict assignment).
777    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(&current).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
840// ── Generation ──────────────────────────────────────────────────────────────
841
842struct 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
852/// `text` still has something to say once `[tags]` and punctuation go.
853fn 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        // A section override rebinds `cfg` to a copy with the new speed — for
969        // the request and for the manifest entry written afterwards.
970        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                            &current_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
1115/// `sfx_common.sfx_dir(slug)`: the per-show library when it exists.
1116fn 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
1395/// The clap definition behind `--help`, for man pages.
1396pub 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}