1use 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 #[arg(long)]
32 episode: Option<String>,
33 #[arg(long)]
35 tag: Option<String>,
36 #[arg(long)]
38 show: Option<String>,
39 #[arg(long)]
41 cues: Option<String>,
42 #[arg(long)]
44 dry_run: bool,
45 #[arg(long)]
47 generate: bool,
48 #[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
104fn 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
123fn 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
577pub 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}