Skip to main content

xil/cmd/
cues.rs

1//! `xil cues` — parse a sound cues & music prompts sheet into an asset
2//! manifest, audit the SFX library, and optionally generate new assets or
3//! enrich the episode SFX config. Port of `XILP006_cues_ingester.py`.
4
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::LazyLock;
9
10use crate::cmd::py_str;
11use clap::Parser;
12use regex::Regex;
13use serde_json::{Map, Value};
14use xil_audio::fx::py_repr;
15use xil_core::pyjson::{dumps, py_float, Style};
16use xil_core::workspace::{derive_paths, resolve_slug, workspace_root};
17use xil_core::{banner, log};
18
19const DEFAULT_SFX_DURATION: f64 = 5.0;
20const API_MAX_DURATION: f64 = 30.0;
21const CREDITS_PER_SECOND: f64 = 40.0;
22
23#[derive(Parser)]
24#[command(
25    name = "xil-cues",
26    about = "Parse a sound cues & music prompts markdown file into an asset manifest, audit the SFX library, and optionally generate new assets or enrich the episode sfx config."
27)]
28#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
29struct Args {
30    /// Episode tag (e.g. S02E03) — derives sfx config path
31    #[arg(long)]
32    episode: Option<String>,
33    /// Raw tag for non-episodic content (e.g. V01C03, D01)
34    #[arg(long)]
35    tag: Option<String>,
36    /// Show name override (default: from project.json)
37    #[arg(long)]
38    show: Option<String>,
39    /// Path to cues markdown file (auto-detected from cues/ if omitted)
40    #[arg(long)]
41    cues: Option<String>,
42    /// Show audit report and enrichment diff without API calls or sfx config writes (manifest is always written)
43    #[arg(long)]
44    dry_run: bool,
45    /// Generate NEW assets via ElevenLabs API into SFX/
46    #[arg(long)]
47    generate: bool,
48    /// Update episode SFX config with cues-sheet prompts/durations
49    #[arg(long)]
50    enrich_sfx_config: bool,
51}
52
53#[derive(Clone, Debug)]
54struct Asset {
55    asset_id: String,
56    category: &'static str,
57    reuse: bool,
58    prompt: Option<String>,
59    duration_seconds: Option<f64>,
60    loop_: bool,
61    scene: Option<String>,
62}
63
64impl Asset {
65    fn json(&self) -> Value {
66        let mut m = Map::new();
67        m.insert("asset_id".into(), self.asset_id.clone().into());
68        m.insert("category".into(), self.category.into());
69        m.insert("reuse".into(), self.reuse.into());
70        m.insert(
71            "prompt".into(),
72            self.prompt.clone().map(Value::from).unwrap_or(Value::Null),
73        );
74        m.insert(
75            "duration_seconds".into(),
76            self.duration_seconds.map(py_float).unwrap_or(Value::Null),
77        );
78        m.insert("loop".into(), self.loop_.into());
79        m.insert(
80            "scene".into(),
81            self.scene.clone().map(Value::from).unwrap_or(Value::Null),
82        );
83        Value::Object(m)
84    }
85}
86
87static LOOP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)\bloop").unwrap());
88static DURATION: LazyLock<Regex> =
89    LazyLock::new(|| Regex::new(r"(?i)(\d+(?:\.\d+)?)\s*(minutes?|min|seconds?|sec|s)\b").unwrap());
90static STARS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*+").unwrap());
91static BLOCK_HEADING: LazyLock<Regex> =
92    LazyLock::new(|| Regex::new(r"(?i)^([\w][\w-]*(?:-\d+)?)\s*\(?(NEW|REUSE)\)?").unwrap());
93static PROMPT: LazyLock<Regex> =
94    LazyLock::new(|| Regex::new(r"\*{1,2}Prompt:\*{1,2}\s*(.*?)\s*\*{1,2}Duration:").unwrap());
95static DURATION_FIELD: LazyLock<Regex> =
96    LazyLock::new(|| Regex::new(r"\*{1,2}Duration:\*{1,2}\s*(.*?)\s*\*{1,2}Used:").unwrap());
97static SCENE: LazyLock<Regex> =
98    LazyLock::new(|| Regex::new(r"^###\s+(?:Scene\s+\d+:\s*)?(.*)").unwrap());
99static ASSET_NAME: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)^Asset\s+Name").unwrap());
100static CAPS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[A-Z]{2,}").unwrap());
101static SFX_ROW: LazyLock<Regex> =
102    LazyLock::new(|| Regex::new(r"(?i)^\*{0,2}([\w][\w-]*-\d+)\s*\((NEW|REUSE)\)\*{0,2}").unwrap());
103
104/// `parse_duration(text)`.
105fn parse_duration(text: &str) -> Option<f64> {
106    let t = text.trim();
107    if t.is_empty() || LOOP.is_match(t) {
108        return None;
109    }
110    let m = DURATION.captures(t)?;
111    let value: f64 = m[1].parse().ok()?;
112    Some(if m[2].to_lowercase().starts_with('m') {
113        value * 60.0
114    } else {
115        value
116    })
117}
118
119fn chars_from(s: &str, n: usize) -> String {
120    s.chars().skip(n).collect()
121}
122
123/// `parse_cues_markdown(path)`.
124fn parse_cues_markdown(path: &Path) -> anyhow::Result<Vec<Asset>> {
125    let text = fs::read_to_string(path)?;
126    let mut assets = Vec::new();
127    let mut section: Option<&'static str> = None;
128    let mut scene: Option<String> = None;
129    let mut pending: Option<Asset> = None;
130    for raw in text.split_inclusive('\n') {
131        let s = raw.trim();
132        if s.starts_with("## ") {
133            let heading = STARS
134                .replace_all(&chars_from(s, 3), "")
135                .trim()
136                .to_uppercase();
137            section = if heading.contains("MUSIC") && heading.contains("CUE") {
138                Some("MUSIC")
139            } else if heading == "AMBIENCE" {
140                Some("AMBIENCE")
141            } else if heading.contains("SOUND EFFECT") {
142                Some("SFX")
143            } else {
144                None
145            };
146            pending = None;
147            scene = None;
148            continue;
149        }
150        let Some(sec) = section else { continue };
151        if (sec == "MUSIC" || sec == "AMBIENCE") && s.starts_with("### ") {
152            let heading = STARS.replace_all(&chars_from(s, 4), "").trim().to_string();
153            if let Some(m) = BLOCK_HEADING.captures(&heading) {
154                pending = Some(Asset {
155                    asset_id: m[1].to_uppercase(),
156                    category: sec,
157                    reuse: m[2].to_uppercase() == "REUSE",
158                    prompt: None,
159                    duration_seconds: None,
160                    loop_: sec == "AMBIENCE",
161                    scene: None,
162                });
163            }
164            continue;
165        }
166        if (sec == "MUSIC" || sec == "AMBIENCE") && pending.is_some() && s.contains("**Prompt:**") {
167            let mut a = pending.take().unwrap();
168            if let Some(pm) = PROMPT.captures(s) {
169                a.prompt = Some(pm[1].trim().to_string());
170            }
171            if let Some(dm) = DURATION_FIELD.captures(s) {
172                let dur_raw = dm[1].trim();
173                a.duration_seconds = parse_duration(dur_raw);
174                if LOOP.is_match(dur_raw) {
175                    a.loop_ = true;
176                }
177            }
178            assets.push(a);
179            continue;
180        }
181        if sec == "SFX" {
182            if s.starts_with("###") {
183                scene = Some(match SCENE.captures(s) {
184                    Some(m) => STARS.replace_all(&m[1], "").trim().to_string(),
185                    None => chars_from(s, 4).trim().to_string(),
186                });
187                continue;
188            }
189            if s.starts_with('|') {
190                let cols: Vec<&str> = s
191                    .split('|')
192                    .map(str::trim)
193                    .filter(|c| !c.is_empty())
194                    .collect();
195                if cols.len() < 2 || ASSET_NAME.is_match(cols[0]) || !CAPS.is_match(cols[0]) {
196                    continue;
197                }
198                if let Some(m) = SFX_ROW.captures(cols[0]) {
199                    assets.push(Asset {
200                        asset_id: m[1].to_uppercase(),
201                        category: "SFX",
202                        reuse: m[2].to_uppercase() == "REUSE",
203                        prompt: Some(cols[1].to_string()),
204                        duration_seconds: None,
205                        loop_: false,
206                        scene: scene.clone(),
207                    });
208                }
209            }
210        }
211    }
212    Ok(assets)
213}
214
215fn library_path(asset_id: &str, sfx_dir: &Path) -> PathBuf {
216    sfx_dir.join(format!("{}.mp3", asset_id.to_lowercase()))
217}
218
219fn status(a: &Asset, sfx_dir: &Path) -> &'static str {
220    if crate::sfxgen::file_nonempty(&library_path(&a.asset_id, sfx_dir)) {
221        "EXISTS"
222    } else if a.reuse {
223        " REUSE"
224    } else {
225        "   NEW"
226    }
227}
228
229fn generation_duration(a: &Asset) -> f64 {
230    match a.duration_seconds {
231        Some(d) if d > 0.0 => d.min(API_MAX_DURATION),
232        _ => DEFAULT_SFX_DURATION,
233    }
234}
235
236fn credits(duration: f64) -> i64 {
237    (duration * CREDITS_PER_SECOND).ceil() as i64
238}
239
240fn prompt_or_crash(a: &Asset) -> anyhow::Result<&str> {
241    a.prompt
242        .as_deref()
243        .ok_or_else(|| anyhow::anyhow!("TypeError: 'NoneType' object is not subscriptable"))
244}
245
246fn dry_run_report(assets: &[Asset], sfx_dir: &Path) -> anyhow::Result<()> {
247    let new_assets: Vec<&Asset> = assets
248        .iter()
249        .filter(|a| !a.reuse && status(a, sfx_dir).trim() == "NEW")
250        .collect();
251    let total_new_dur: f64 = new_assets.iter().map(|a| generation_duration(a)).sum();
252    let total_credits: i64 = new_assets
253        .iter()
254        .map(|a| credits(generation_duration(a)))
255        .sum();
256    let capped = new_assets
257        .iter()
258        .filter(|a| a.duration_seconds.unwrap_or(0.0) > API_MAX_DURATION)
259        .count();
260    let exists = assets
261        .iter()
262        .filter(|a| status(a, sfx_dir) == "EXISTS")
263        .count();
264    let reuse_missing = assets
265        .iter()
266        .filter(|a| a.reuse && status(a, sfx_dir).trim() == "REUSE")
267        .count();
268    let bar = "=".repeat(72);
269    log::info(&format!("\n{bar}"));
270    log::info(&format!("CUES SHEET AUDIT — {} assets total", assets.len()));
271    log::info(&format!(
272        "  {exists} in library  |  {} new to generate  |  {reuse_missing} REUSE not yet in library",
273        new_assets.len()
274    ));
275    log::info(&format!("{bar}\n"));
276    for cat in ["MUSIC", "AMBIENCE", "SFX"] {
277        let cat_assets: Vec<&Asset> = assets.iter().filter(|a| a.category == cat).collect();
278        if cat_assets.is_empty() {
279            continue;
280        }
281        log::info(&format!("  ── {cat} ──"));
282        for a in cat_assets {
283            let st = status(a, sfx_dir);
284            let api_dur = generation_duration(a);
285            let dur = a.duration_seconds.filter(|d| *d != 0.0);
286            let dur_str = match dur {
287                Some(d) => format!("{d:.0}s"),
288                None => format!("~{api_dur:.0}s"),
289            };
290            let cap_note = match dur {
291                Some(d) if d > API_MAX_DURATION => format!(" [CAPPED→{API_MAX_DURATION:.0}s]"),
292                _ => String::new(),
293            };
294            let credits_note = if st.trim() == "NEW" {
295                format!("  ~{} cr", credits(api_dur))
296            } else {
297                String::new()
298            };
299            let loop_note = if a.loop_ { " [loop]" } else { "" };
300            log::info(&format!(
301                "    [{st}] {:<32} {dur_str:>8}{cap_note}{credits_note}{loop_note}",
302                a.asset_id
303            ));
304            if st.trim() == "NEW" {
305                let p = prompt_or_crash(a)?;
306                let mut t: String = p.chars().take(72).collect();
307                if p.chars().count() > 72 {
308                    t.push('…');
309                }
310                log::info(&format!("            prompt: {t}"));
311            }
312        }
313        log::info("");
314    }
315    if capped > 0 {
316        log::info(&format!(
317            "  NOTE: {capped} asset(s) exceed the {API_MAX_DURATION:.0}s API cap and will be generated at 30s."
318        ));
319        log::info("");
320    }
321    log::info(&bar);
322    log::info(&format!(
323        "  New generation: {total_new_dur:.1}s total, ~{total_credits} credits"
324    ));
325    log::info(&format!("{bar}\n"));
326    Ok(())
327}
328
329fn generate_new_assets(
330    assets: &[Asset],
331    sfx_dir: &Path,
332    client: &xil_api::elevenlabs::Client,
333) -> anyhow::Result<()> {
334    fs::create_dir_all(sfx_dir)?;
335    let todo: Vec<&Asset> = assets
336        .iter()
337        .filter(|a| !a.reuse && status(a, sfx_dir).trim() == "NEW")
338        .collect();
339    if todo.is_empty() {
340        log::info("All NEW assets already exist in library — nothing to generate.");
341        return Ok(());
342    }
343    log::info(&format!(
344        "Generating {} new asset(s) into {}/…",
345        todo.len(),
346        sfx_dir.display()
347    ));
348    for a in &todo {
349        let path = library_path(&a.asset_id, sfx_dir);
350        let dur = generation_duration(a);
351        if let Some(orig) = a
352            .duration_seconds
353            .filter(|d| *d != 0.0 && *d > API_MAX_DURATION)
354        {
355            log::warning(&format!(
356                "{}: {orig:.0}s capped to {API_MAX_DURATION:.0}s for API",
357                a.asset_id
358            ));
359        }
360        log::info(&format!("  Generating {} ({dur:.1}s)…", a.asset_id));
361        let bytes = client
362            .sound_generation(a.prompt.as_deref().unwrap_or(""), Some(dur), Some(0.3))
363            .map_err(|e| anyhow::anyhow!("elevenlabs.core.api_error.ApiError: {e}"))?;
364        let dir = path
365            .parent()
366            .filter(|p| !p.as_os_str().is_empty())
367            .unwrap_or(Path::new("."));
368        let tmp = tempfile::Builder::new()
369            .prefix("tmp")
370            .rand_bytes(8)
371            .suffix(".tmp")
372            .tempfile_in(dir)?;
373        fs::write(tmp.path(), &bytes)?;
374        tmp.persist(&path).map_err(|e| anyhow::anyhow!(e.error))?;
375        log::info(&format!("    → {}", path.display()));
376    }
377    log::info(&format!("Done. {} asset(s) generated.", todo.len()));
378    Ok(())
379}
380
381fn enrich_sfx_config(assets: &[Asset], path: &Path, dry_run: bool) -> anyhow::Result<()> {
382    let mut config: Value = serde_json::from_str(&fs::read_to_string(path)?)?;
383    let mut updates = 0usize;
384    {
385        let mut empty = Map::new();
386        let effects = match config.get_mut("effects").and_then(Value::as_object_mut) {
387            Some(e) => e,
388            None => &mut empty,
389        };
390        for a in assets {
391            let id = a.asset_id.to_uppercase();
392            let keys: Vec<String> = effects
393                .keys()
394                .filter(|k| k.to_uppercase().contains(&id))
395                .cloned()
396                .collect();
397            if keys.is_empty() {
398                continue;
399            }
400            let new_prompt = a.prompt.clone().unwrap_or_default();
401            let new_duration = generation_duration(a);
402            for key in keys {
403                let entry = effects
404                    .get_mut(&key)
405                    .and_then(Value::as_object_mut)
406                    .expect("effect entries are objects");
407                let old_prompt = entry
408                    .get("prompt")
409                    .cloned()
410                    .unwrap_or_else(|| Value::from(""));
411                let old_duration = entry
412                    .get("duration_seconds")
413                    .cloned()
414                    .unwrap_or_else(|| py_float(0.0));
415                let prompt_changed =
416                    !new_prompt.is_empty() && old_prompt.as_str() != Some(new_prompt.as_str());
417                let dur_changed =
418                    (new_duration - old_duration.as_f64().unwrap_or(0.0)).abs() >= 0.5;
419                if !prompt_changed && !dur_changed {
420                    continue;
421                }
422                updates += 1;
423                if dry_run {
424                    log::info(&format!("  WOULD UPDATE: {key}"));
425                    if prompt_changed {
426                        let old = match &old_prompt {
427                            Value::String(s) => py_repr(s),
428                            other => py_str(other),
429                        };
430                        log::info(&format!("    prompt: {old}"));
431                        log::info(&format!("         → {}", py_repr(&new_prompt)));
432                    }
433                    if dur_changed {
434                        log::info(&format!(
435                            "    duration: {}s → {}s",
436                            py_str(&old_duration),
437                            xil_core::pyjson::float_repr(new_duration)
438                        ));
439                    }
440                } else {
441                    if prompt_changed {
442                        entry.insert("prompt".into(), new_prompt.clone().into());
443                    }
444                    entry.insert("duration_seconds".into(), py_float(new_duration));
445                    if a.loop_ {
446                        entry.insert("loop".into(), true.into());
447                    }
448                }
449            }
450        }
451    }
452    let noun = if updates == 1 { "y" } else { "ies" };
453    if !dry_run && updates > 0 {
454        fs::write(path, dumps(&config, Style::INDENT2))?;
455        log::info(&format!(
456            "Updated {updates} entr{noun} in {}",
457            path.display()
458        ));
459    } else if updates == 0 {
460        log::info("No sfx config entries matched cues sheet assets — nothing to update.");
461    } else {
462        log::info(&format!(
463            "\n{updates} entr{noun} would be updated (pass --enrich-sfx-config without --dry-run to apply)."
464        ));
465    }
466    Ok(())
467}
468
469pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
470    log::init("cues");
471    let _banner = banner::begin(super::prog(), &super::argv_line(args));
472    let a: Args = match super::parse_or_exit("xil-cues", args) {
473        Ok(a) => a,
474        Err(code) => return Ok(code),
475    };
476    let tag = a
477        .episode
478        .clone()
479        .or_else(|| a.tag.clone())
480        .unwrap_or_default();
481    let slug = resolve_slug(a.show.as_deref(), "project.json");
482    let p = derive_paths(&slug, &tag);
483    let root = workspace_root();
484    let cues_dir = root.join("cues");
485    let sfx_dir = root.join("SFX");
486
487    let cues_path: Option<PathBuf> = match a.cues.as_ref().filter(|c| !c.is_empty()) {
488        Some(c) => Some(PathBuf::from(c)),
489        None if cues_dir.is_dir() => {
490            if p["cues"].exists() {
491                Some(p["cues"].clone())
492            } else {
493                let md: Vec<PathBuf> = xil_core::fsutil::list_dir_raw(&cues_dir)
494                    .into_iter()
495                    .filter(|x| {
496                        let n = xil_core::fsutil::basename(x);
497                        n.ends_with(".md") && !n.starts_with('.')
498                    })
499                    .collect();
500                if md.len() == 1 {
501                    md.into_iter().next()
502                } else {
503                    None
504                }
505            }
506        }
507        None => None,
508    };
509    let Some(cues_path) = cues_path else {
510        eprintln!("usage: xil-cues [-h] (--episode EPISODE | --tag TAG) [--show SHOW] [--cues CUES] [--dry-run] [--generate] [--enrich-sfx-config]");
511        eprintln!(
512            "xil-cues: error: No cues file found for {tag}. Pass --cues PATH or name your file {}",
513            p["cues"].display()
514        );
515        return Ok(2);
516    };
517
518    log::info(&format!("Parsing: {}", cues_path.display()));
519    let assets = parse_cues_markdown(&cues_path)?;
520    let new_count = assets.iter().filter(|x| !x.reuse).count();
521    log::info(&format!(
522        "Found {} assets ({new_count} new, {} reuse)",
523        assets.len(),
524        assets.len() - new_count
525    ));
526
527    let mut manifest = Map::new();
528    manifest.insert("episode".into(), tag.clone().into());
529    manifest.insert(
530        "source".into(),
531        xil_core::fsutil::basename(&cues_path).into(),
532    );
533    manifest.insert("total_assets".into(), assets.len().into());
534    manifest.insert("new_count".into(), new_count.into());
535    manifest.insert("reuse_count".into(), (assets.len() - new_count).into());
536    manifest.insert(
537        "assets".into(),
538        Value::Array(assets.iter().map(Asset::json).collect()),
539    );
540    fs::create_dir_all(&cues_dir)?;
541    let out = cues_dir.join(format!("cues_manifest_{tag}.json"));
542    fs::write(&out, dumps(&Value::Object(manifest), Style::INDENT2))?;
543    log::info(&format!("Manifest written: {}", out.display()));
544    dry_run_report(&assets, &sfx_dir)?;
545
546    if a.generate {
547        if a.dry_run {
548            log::info("--dry-run active: skipping API generation.");
549        } else {
550            match std::env::var("ELEVENLABS_API_KEY")
551                .ok()
552                .filter(|k| !k.is_empty())
553            {
554                None => log::error("ELEVENLABS_API_KEY not set. Cannot generate assets."),
555                Some(key) => {
556                    let client = xil_api::elevenlabs::Client::new(Some(key));
557                    generate_new_assets(&assets, &sfx_dir, &client)?;
558                }
559            }
560        }
561    }
562    if a.enrich_sfx_config {
563        let sfx = &p["sfx"];
564        if !sfx.exists() {
565            log::warning(&format!(
566                "{} not found — skipping sfx config enrichment.",
567                sfx.display()
568            ));
569        } else {
570            log::info(&format!("\nEnriching {}…", sfx.display()));
571            enrich_sfx_config(&assets, sfx, a.dry_run)?;
572        }
573    }
574    Ok(0)
575}
576
577/// The clap definition behind `--help`, for man pages.
578pub fn command() -> clap::Command {
579    <Args as clap::CommandFactory>::command()
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    #[test]
587    fn durations_parse_like_python() {
588        assert_eq!(parse_duration("60 seconds"), Some(60.0));
589        assert_eq!(parse_duration("2 minutes"), Some(120.0));
590        assert_eq!(parse_duration("1.5 min"), Some(90.0));
591        assert_eq!(parse_duration("Loop"), None);
592        assert_eq!(parse_duration("loopable, 30s"), None);
593        assert_eq!(parse_duration("about 45s"), Some(45.0));
594        assert_eq!(parse_duration("forever"), None);
595    }
596}