1use std::collections::BTreeSet;
10use std::ffi::OsString;
11use std::fs;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::sync::LazyLock;
15
16use clap::Parser;
17use regex::Regex;
18use serde_json::{Map, Value};
19use xil_core::fsutil::{abspath, basename, relpath};
20use xil_core::journal::append_sfx_edit;
21use xil_core::pycsv;
22use xil_core::pyfmt::{fixed, head, pad_right, round_to};
23use xil_core::pyjson::{dumps, py_float, Style};
24use xil_core::sfxlib::{shared_sfx_path, slugify_effect_key};
25use xil_core::workspace::{resolve_slug, workspace_root};
26use xil_core::{banner, log};
27
28use super::sfx_impact::{classify_placement, discover_configs};
29use super::sfx_lib::fetch_local_records;
30
31const SCRIPT_NAME: &str = "XILU022_sfx_match";
32
33const TIERS: [&str; 4] = ["EXACT", "STRONG", "REVIEW", "NONE"];
34
35const STOPWORDS: [&str; 32] = [
38 "sfx",
39 "ambience",
40 "ambient",
41 "music",
42 "beat",
43 "new",
44 "stem",
45 "needed",
46 "mp3",
47 "wav",
48 "elevenlabs",
49 "a",
50 "an",
51 "and",
52 "as",
53 "at",
54 "by",
55 "for",
56 "from",
57 "in",
58 "into",
59 "it",
60 "its",
61 "of",
62 "on",
63 "or",
64 "s",
65 "the",
66 "then",
67 "to",
68 "with",
69 "",
70];
71
72const REVIEW_FLOOR: f64 = 0.5;
73const STRONG_COVERAGE: f64 = 0.8;
74const STRONG_JACCARD: f64 = 0.4;
75const STRONG_MARGIN: f64 = 0.15;
76const JACCARD_WEIGHT: f64 = 0.25;
77
78const CSV_COLUMNS: [&str; 14] = [
79 "show",
80 "episode",
81 "cue",
82 "current_source",
83 "tier",
84 "rank",
85 "candidate",
86 "candidate_title",
87 "candidate_scope",
88 "coverage",
89 "jaccard",
90 "score",
91 "duration_s",
92 "also_in",
93];
94
95static TOKEN_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^a-z0-9]+").unwrap());
96
97#[derive(Parser)]
98#[command(
99 name = "xil-sfx-match",
100 about = "Find existing SFX library assets for cues whose declared source is not on disk — the cues that make 'xil produce' refuse to start. Reports by default; --apply copies the match into the show's pool and journals the new source."
101)]
102struct Args {
103 #[arg(long)]
105 show: Option<String>,
106 #[arg(long, alias = "tag")]
108 episode: Option<String>,
109 #[arg(long, default_value_t = 3)]
111 top: usize,
112 #[arg(long, default_value_t = REVIEW_FLOOR)]
114 min_coverage: f64,
115 #[arg(long)]
117 output: Option<String>,
118 #[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
120 emit_hints: Option<String>,
121 #[arg(long)]
123 apply: bool,
124 #[arg(long)]
126 accept_review: bool,
127 #[arg(long)]
129 dry_run: bool,
130 #[arg(long)]
132 quiet: bool,
133}
134
135fn singular(token: &str) -> String {
139 if token.chars().count() >= 4 && token.ends_with('s') && !token.ends_with("ss") {
140 token[..token.len() - 1].to_string()
141 } else {
142 token.to_string()
143 }
144}
145
146pub fn tokenize(text: &str) -> BTreeSet<String> {
148 let lower = text.to_lowercase();
149 TOKEN_SPLIT
150 .split(&lower)
151 .filter(|t| !STOPWORDS.contains(t))
152 .map(singular)
153 .collect()
154}
155
156fn score_pair(cue: &BTreeSet<String>, cand: &BTreeSet<String>) -> (f64, f64) {
158 if cue.is_empty() || cand.is_empty() {
159 return (0.0, 0.0);
160 }
161 let shared = cue.intersection(cand).count() as f64;
162 let union = cue.union(cand).count() as f64;
163 (shared / cue.len() as f64, shared / union)
164}
165
166fn composite(coverage: f64, jaccard: f64) -> f64 {
167 coverage + JACCARD_WEIGHT * jaccard
168}
169
170fn fourth_is_alpha(f: &str) -> bool {
172 f.chars().nth(3).is_some_and(char::is_alphabetic)
173}
174
175fn placement_from_filename(filename: &str) -> &'static str {
177 let f = filename.to_lowercase();
178 if ["ambience_", "ambience-", "amb-", "amb_"]
179 .iter()
180 .any(|p| f.starts_with(p))
181 || (f.starts_with("amb") && fourth_is_alpha(&f))
182 {
183 return "AMBI(bg)";
184 }
185 if ["music_", "music-", "mus-", "mus_"]
186 .iter()
187 .any(|p| f.starts_with(p))
188 || (f.starts_with("mus") && fourth_is_alpha(&f))
189 {
190 return "MUSIC(bg)";
191 }
192 if f.starts_with("beat") {
193 return "BEAT(fg)";
194 }
195 "SFX(fg)"
196}
197
198#[derive(Debug, Clone)]
201struct PoolAsset {
202 filename: String,
203 path: String,
204 scope: String,
206 title: String,
207 prompt: String,
208 duration_s: Option<f64>,
209 placement: &'static str,
210 tokens: BTreeSet<String>,
211 also_in: Vec<String>,
212}
213
214impl PoolAsset {
215 fn match_text(&self) -> &str {
217 if !self.title.is_empty() {
218 &self.title
219 } else if !self.prompt.is_empty() {
220 &self.prompt
221 } else {
222 &self.filename
223 }
224 }
225}
226
227fn scope_rank(scope: &str, own_show: Option<&str>) -> u8 {
228 if own_show.is_some_and(|s| s == scope) {
229 0
230 } else if scope.is_empty() {
231 1
232 } else {
233 2
234 }
235}
236
237fn is_canonical_name(a: &PoolAsset) -> bool {
239 !a.title.is_empty() && a.filename == format!("{}.mp3", slugify_effect_key(&a.title))
240}
241
242fn asset_from_record(rec: &Map<String, Value>) -> PoolAsset {
243 let s = |k: &str| rec.get(k).and_then(Value::as_str).unwrap_or("");
244 let title = s("title").trim().to_string();
245 let filename = s("filename").to_string();
246 let placement = if title.is_empty() {
247 placement_from_filename(&filename)
248 } else {
249 classify_placement(&title)
250 };
251 let mut a = PoolAsset {
252 filename,
253 path: s("path").to_string(),
254 scope: s("show").to_string(),
255 title,
256 prompt: s("prompt").trim().to_string(),
257 duration_s: rec.get("duration_seconds").and_then(Value::as_f64),
258 placement,
259 tokens: BTreeSet::new(),
260 also_in: Vec::new(),
261 };
262 a.tokens = tokenize(a.match_text());
263 a
264}
265
266fn load_assets(workspace: &Path) -> Vec<PoolAsset> {
267 fetch_local_records(&workspace.join("SFX"))
268 .iter()
269 .map(asset_from_record)
270 .collect()
271}
272
273fn build_pool(assets: &[PoolAsset], own_show: Option<&str>) -> Vec<PoolAsset> {
275 let mut groups: indexmap::IndexMap<(String, Option<u64>), Vec<&PoolAsset>> =
276 indexmap::IndexMap::new();
277 for a in assets {
278 let key = (
279 a.tokens.iter().cloned().collect::<Vec<_>>().join(" "),
280 a.duration_s.map(f64::to_bits),
281 );
282 groups.entry(key).or_default().push(a);
283 }
284 let mut pool = Vec::new();
285 for members in groups.values_mut() {
286 members.sort_by(|x, y| {
287 (
288 scope_rank(&x.scope, own_show),
289 !is_canonical_name(x),
290 x.filename.chars().count(),
291 &x.filename,
292 )
293 .cmp(&(
294 scope_rank(&y.scope, own_show),
295 !is_canonical_name(y),
296 y.filename.chars().count(),
297 &y.filename,
298 ))
299 });
300 let also_in: BTreeSet<String> = members[1..]
301 .iter()
302 .map(|m| {
303 if m.scope.is_empty() {
304 "SFX/".to_string()
305 } else {
306 m.scope.clone()
307 }
308 })
309 .collect();
310 let mut winner = members[0].clone();
311 winner.also_in = also_in.into_iter().collect();
312 pool.push(winner);
313 }
314 pool
315}
316
317fn build_exact_index(
319 assets: &[PoolAsset],
320 own_show: Option<&str>,
321) -> indexmap::IndexMap<String, PoolAsset> {
322 let mut index: indexmap::IndexMap<String, PoolAsset> = indexmap::IndexMap::new();
323 for a in assets {
324 let replace = match index.get(&a.filename) {
325 None => true,
326 Some(cur) => scope_rank(&a.scope, own_show) < scope_rank(&cur.scope, own_show),
327 };
328 if replace {
329 index.insert(a.filename.clone(), a.clone());
330 }
331 }
332 index
333}
334
335#[derive(Debug, Clone)]
338struct Candidate {
339 asset: PoolAsset,
340 coverage: f64,
341 jaccard: f64,
342}
343
344impl Candidate {
345 fn score(&self) -> f64 {
346 composite(self.coverage, self.jaccard)
347 }
348}
349
350struct CueMatch {
351 show: String,
352 episode: String,
353 cue: String,
354 current_source: String,
355 config_path: PathBuf,
356 tier: &'static str,
357 candidates: Vec<Candidate>,
358}
359
360impl CueMatch {
361 fn best(&self) -> Option<&Candidate> {
362 self.candidates.first()
363 }
364
365 fn rows(&self) -> Vec<Map<String, Value>> {
366 let base = || {
367 let mut m = Map::new();
368 m.insert("show".into(), self.show.clone().into());
369 m.insert("episode".into(), self.episode.clone().into());
370 m.insert("cue".into(), self.cue.clone().into());
371 m.insert("current_source".into(), self.current_source.clone().into());
372 m.insert("tier".into(), self.tier.into());
373 m
374 };
375 if self.candidates.is_empty() {
376 let mut m = base();
377 for c in &CSV_COLUMNS[5..] {
378 m.insert((*c).into(), "".into());
379 }
380 return vec![m];
381 }
382 self.candidates
383 .iter()
384 .enumerate()
385 .map(|(i, c)| {
386 let mut m = base();
387 m.insert("rank".into(), Value::from(i + 1));
388 m.insert("candidate".into(), c.asset.filename.clone().into());
389 m.insert("candidate_title".into(), c.asset.match_text().into());
390 m.insert(
391 "candidate_scope".into(),
392 if c.asset.scope.is_empty() {
393 "SFX/".into()
394 } else {
395 c.asset.scope.clone().into()
396 },
397 );
398 m.insert("coverage".into(), py_float(round_to(c.coverage, 2)));
399 m.insert("jaccard".into(), py_float(round_to(c.jaccard, 2)));
400 m.insert("score".into(), py_float(round_to(c.score(), 3)));
401 m.insert(
402 "duration_s".into(),
403 c.asset.duration_s.map_or("".into(), py_float),
404 );
405 m.insert("also_in".into(), c.asset.also_in.join(", ").into());
406 m
407 })
408 .collect()
409 }
410}
411
412fn match_cue(
413 cue: &str,
414 pool: &[PoolAsset],
415 exact_index: &indexmap::IndexMap<String, PoolAsset>,
416 top: usize,
417 min_coverage: f64,
418) -> (&'static str, Vec<Candidate>) {
419 let exact_name = format!("{}.mp3", slugify_effect_key(cue));
420 let hit = exact_index
421 .get(&exact_name)
422 .or_else(|| pool.iter().find(|a| a.filename == exact_name));
423 if let Some(hit) = hit {
424 return (
425 "EXACT",
426 vec![Candidate {
427 asset: hit.clone(),
428 coverage: 1.0,
429 jaccard: 1.0,
430 }],
431 );
432 }
433
434 let cue_tokens = tokenize(cue);
435 let wanted = classify_placement(cue);
436 let mut scored: Vec<Candidate> = pool
437 .iter()
438 .filter(|a| a.placement == wanted)
439 .filter_map(|a| {
440 let (cov, jac) = score_pair(&cue_tokens, &a.tokens);
441 (cov >= min_coverage).then(|| Candidate {
442 asset: a.clone(),
443 coverage: cov,
444 jaccard: jac,
445 })
446 })
447 .collect();
448 scored.sort_by(|x, y| {
449 y.score()
450 .partial_cmp(&x.score())
451 .unwrap_or(std::cmp::Ordering::Equal)
452 .then_with(|| x.asset.filename.cmp(&y.asset.filename))
453 });
454 scored.truncate(top);
455
456 let Some(leader) = scored.first() else {
457 return ("NONE", scored);
458 };
459 let margin = leader.score() - scored.get(1).map_or(0.0, Candidate::score);
460 let tier = if leader.coverage >= STRONG_COVERAGE
461 && leader.jaccard >= STRONG_JACCARD
462 && margin >= STRONG_MARGIN
463 {
464 "STRONG"
465 } else {
466 "REVIEW"
467 };
468 (tier, scored)
469}
470
471fn source_missing(source: &str, workspace: &Path) -> bool {
472 if source.is_empty() {
473 return false;
474 }
475 let p = Path::new(source);
476 let p = if p.is_absolute() {
477 p.to_path_buf()
478 } else {
479 workspace.join(source)
480 };
481 !p.is_file()
482}
483
484fn analyze(
485 workspace: &Path,
486 show: Option<&str>,
487 episode: Option<&str>,
488 top: usize,
489 min_coverage: f64,
490) -> (Vec<CueMatch>, usize) {
491 let configs = discover_configs(workspace, show, episode);
492 let mut pools: indexmap::IndexMap<String, Vec<PoolAsset>> = indexmap::IndexMap::new();
493 let mut indexes: indexmap::IndexMap<String, indexmap::IndexMap<String, PoolAsset>> =
494 indexmap::IndexMap::new();
495 let mut assets: Option<Vec<PoolAsset>> = None;
496 let mut matches = Vec::new();
497
498 for (slug, tag, path) in &configs {
499 let data: Value = match fs::read_to_string(path)
500 .map_err(|e| e.to_string())
501 .and_then(|t| serde_json::from_str(&t).map_err(|e| e.to_string()))
502 {
503 Ok(v) => v,
504 Err(e) => {
505 log::warning(&format!(" Skipping {} — {e}", path.display()));
506 continue;
507 }
508 };
509 let effects = data
510 .get("effects")
511 .and_then(Value::as_object)
512 .cloned()
513 .unwrap_or_default();
514 for (cue, effect) in &effects {
515 let source = effect
516 .get("source")
517 .filter(|v| super::truthy(v))
518 .map(xil_core::workspace::python_str)
519 .unwrap_or_default();
520 if !source_missing(&source, workspace) {
521 continue;
522 }
523 let all = assets.get_or_insert_with(|| {
524 log::info(" Indexing the SFX library…");
527 let a = load_assets(workspace);
528 log::info(&format!(" Indexed {} asset(s)", a.len()));
529 a
530 });
531 if !pools.contains_key(slug) {
532 pools.insert(slug.clone(), build_pool(all, Some(slug)));
533 indexes.insert(slug.clone(), build_exact_index(all, Some(slug)));
534 }
535 let (tier, candidates) =
536 match_cue(cue, &pools[slug], &indexes[slug], top, min_coverage);
537 matches.push(CueMatch {
538 show: slug.clone(),
539 episode: tag.clone(),
540 cue: cue.clone(),
541 current_source: source,
542 config_path: path.clone(),
543 tier,
544 candidates,
545 });
546 }
547 }
548 (matches, configs.len())
549}
550
551fn sfx_dir(slug: &str) -> PathBuf {
555 let root = workspace_root();
556 let per_show = root.join("SFX").join(slug);
557 if per_show.is_dir() {
558 per_show
559 } else {
560 root.join("SFX")
561 }
562}
563
564fn write_config_source(config_path: &Path, cue: &str, source: &str) -> anyhow::Result<()> {
565 let mut data: Value = serde_json::from_str(&fs::read_to_string(config_path)?)?;
566 let obj = data
567 .as_object_mut()
568 .ok_or_else(|| anyhow::anyhow!("config is not an object"))?;
569 let effects = obj
570 .entry("effects")
571 .or_insert_with(|| Value::Object(Map::new()));
572 let entry = effects
573 .as_object_mut()
574 .ok_or_else(|| anyhow::anyhow!("effects is not an object"))?
575 .entry(cue.to_string())
576 .or_insert_with(|| Value::Object(Map::new()));
577 if let Some(e) = entry.as_object_mut() {
578 e.insert("source".into(), source.into());
579 }
580 fs::write(config_path, dumps(&data, Style::INDENT2_UTF8) + "\n")?;
581 Ok(())
582}
583
584fn copy2(src: &Path, dst: &Path) -> std::io::Result<()> {
586 fs::copy(src, dst)?;
587 if let Ok(m) = fs::metadata(src).and_then(|m| m.modified()) {
588 let f = fs::File::options().write(true).open(dst)?;
589 f.set_modified(m)?;
590 }
591 Ok(())
592}
593
594fn apply_match(m: &CueMatch, workspace: &Path, dry_run: bool) -> anyhow::Result<()> {
595 let Some(best) = m.best() else {
596 return Ok(());
597 };
598 let dest = shared_sfx_path(&sfx_dir(&m.show), &m.cue, "elevenlabs");
599 let rel_dest = relpath(&dest, workspace).to_string_lossy().into_owned();
600 let src = PathBuf::from(&best.asset.path);
601 let rel_src = relpath(&src, workspace).to_string_lossy().into_owned();
602
603 let action = if abspath(&dest) == abspath(&src) {
604 "already in place".to_string()
605 } else if dest.exists() {
606 "destination exists, reusing".to_string()
607 } else {
608 if !dry_run {
609 if let Some(parent) = dest.parent() {
610 fs::create_dir_all(parent)?;
611 }
612 copy2(&src, &dest)?;
613 xil_audio::tags::retag_title_comment(
614 &dest,
615 &m.cue,
616 &format!("copied from {rel_src} by xil sfx-match"),
617 )?;
618 }
619 format!("copy {rel_src}")
620 };
621
622 log::info(&format!(
623 " {}{} → {} ({})",
624 if dry_run { "[dry-run] " } else { "" },
625 head(&m.cue, 44),
626 rel_dest,
627 action
628 ));
629
630 if !dry_run {
631 write_config_source(&m.config_path, &m.cue, &rel_dest)?;
632 let mut fields = Map::new();
633 fields.insert("source".into(), rel_dest.clone().into());
634 append_sfx_edit(&m.config_path, &m.cue, &fields)?;
635 }
636 Ok(())
637}
638
639fn write_csv<W: Write>(matches: &[CueMatch], out: &mut W) -> std::io::Result<()> {
642 let rows: Vec<Map<String, Value>> = matches.iter().flat_map(CueMatch::rows).collect();
643 pycsv::write_dicts(out, &CSV_COLUMNS, &rows)
644}
645
646fn render_hints(matches: &[CueMatch], workspace: &Path) -> String {
647 let today = chrono::Local::now().format("%Y-%m-%d");
648 let mut out: Vec<String> = vec![
649 "# SFX Pipe-Hint Repairs".into(),
650 format!("# Generated {today} by xil sfx-match"),
651 "# Replace the matching direction line in the script with the [CUE | file] line.".into(),
652 "# Alternates are listed beneath each match — swap in whichever is right.".into(),
653 String::new(),
654 ];
655 for tier in ["EXACT", "STRONG", "REVIEW"] {
656 let rows: Vec<&CueMatch> = matches.iter().filter(|m| m.tier == tier).collect();
657 if rows.is_empty() {
658 continue;
659 }
660 out.push(format!("## {tier} ({})", rows.len()));
661 out.push(String::new());
662 for m in rows {
663 let dest = basename(&shared_sfx_path(&sfx_dir(&m.show), &m.cue, "elevenlabs"));
664 out.push(format!("[{} | {dest}]", m.cue));
665 for c in &m.candidates {
666 let rel = relpath(Path::new(&c.asset.path), workspace)
667 .to_string_lossy()
668 .into_owned();
669 out.push(format!(
670 "# cov {} “{}”",
671 fixed(c.coverage, 2),
672 c.asset.match_text()
673 ));
674 out.push(format!("# {rel}"));
675 }
676 out.push(String::new());
677 }
678 }
679 let none_rows: Vec<&CueMatch> = matches.iter().filter(|m| m.tier == "NONE").collect();
680 if !none_rows.is_empty() {
681 out.push(format!(
682 "## NONE — no existing asset, needs generation ({})",
683 none_rows.len()
684 ));
685 out.push(String::new());
686 out.extend(
687 none_rows
688 .iter()
689 .map(|m| format!("# {}/{} {}", m.show, m.episode, m.cue)),
690 );
691 out.push(String::new());
692 }
693 out.join("\n")
694}
695
696fn log_summary(matches: &[CueMatch], configs_scanned: usize) {
697 let mut by_show: indexmap::IndexMap<&str, indexmap::IndexMap<&str, usize>> =
698 indexmap::IndexMap::new();
699 for m in matches {
700 *by_show
701 .entry(m.show.as_str())
702 .or_insert_with(|| TIERS.iter().map(|t| (*t, 0)).collect())
703 .entry(m.tier)
704 .or_insert(0) += 1;
705 }
706
707 log::info("");
708 log::info(&format!(
709 " Scanned {configs_scanned} SFX config(s), {} cue(s) with an unresolvable source",
710 matches.len()
711 ));
712 if matches.is_empty() {
713 log::info("");
714 log::info(" Every declared source resolves — nothing to match.");
715 return;
716 }
717
718 log::info("");
719 let row = |name: &str, t: &indexmap::IndexMap<&str, usize>| {
720 format!(
721 " {} {:>7} {:>7} {:>7} {:>7}",
722 pad_right(name, 24),
723 t["EXACT"],
724 t["STRONG"],
725 t["REVIEW"],
726 t["NONE"]
727 )
728 };
729 let header = format!(
730 " {} {:>7} {:>7} {:>7} {:>7}",
731 pad_right("show", 24),
732 "EXACT",
733 "STRONG",
734 "REVIEW",
735 "NONE"
736 );
737 let rule = format!(" {}", "-".repeat(header.chars().count() - 2));
738 log::info(&header);
739 log::info(&rule);
740 let mut totals: indexmap::IndexMap<&str, usize> = TIERS.iter().map(|t| (*t, 0)).collect();
741 let mut shows: Vec<_> = by_show.iter().collect();
742 shows.sort_by(|a, b| a.0.cmp(b.0));
743 for (slug, tiers) in shows {
744 log::info(&row(slug, tiers));
745 for t in TIERS {
746 totals[t] += tiers[t];
747 }
748 }
749 log::info(&rule);
750 log::info(&row("TOTAL", &totals));
751
752 let review: Vec<&CueMatch> = matches.iter().filter(|m| m.tier == "REVIEW").collect();
753 if !review.is_empty() {
754 log::info("");
755 log::info(&format!(" {} cue(s) need a human decision:", review.len()));
756 for m in review.iter().take(15) {
757 let Some(best) = m.best() else { continue };
758 log::info(&format!(
759 " {} cov {} “{}”",
760 pad_right(&head(&m.cue, 42), 42),
761 fixed(best.coverage, 2),
762 head(best.asset.match_text(), 52)
763 ));
764 }
765 if review.len() > 15 {
766 log::info(&format!(
767 " … and {} more — see the CSV",
768 review.len() - 15
769 ));
770 }
771 }
772
773 let nothing = matches.iter().filter(|m| m.tier == "NONE").count();
774 if nothing > 0 {
775 log::info("");
776 log::info(&format!(
777 " {nothing} cue(s) have no existing asset and need generation."
778 ));
779 }
780}
781
782fn execute(a: &Args) -> anyhow::Result<i32> {
785 let workspace = workspace_root();
786 let show = match (&a.show, &a.episode) {
787 (None, Some(_)) => Some(resolve_slug(None, "project.json")),
788 (s, _) => s.clone(),
789 };
790 let to_stdout = a.output.as_deref() == Some("-");
791 let (matches, configs_scanned) = analyze(
792 &workspace,
793 show.as_deref(),
794 a.episode.as_deref(),
795 a.top,
796 a.min_coverage,
797 );
798
799 if configs_scanned == 0 {
800 let scope = match &show {
801 Some(s) => format!("show={s}"),
802 None => "workspace".to_string(),
803 };
804 let msg = format!(
805 "No SFX configs found ({scope}) under {}",
806 workspace.join("configs").display()
807 );
808 if to_stdout {
809 eprintln!("{msg}");
810 } else {
811 log::error(&msg);
812 }
813 return Ok(1);
814 }
815
816 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
817 if to_stdout {
818 let mut out = std::io::stdout().lock();
819 write_csv(&matches, &mut out)?;
820 out.flush()?;
821 } else {
822 let out_path = match &a.output {
823 Some(p) => PathBuf::from(p),
824 None => workspace
825 .join("reports")
826 .join(format!("sfx_match_{date}.csv")),
827 };
828 if let Some(parent) = out_path.parent() {
829 fs::create_dir_all(parent)?;
830 }
831 let mut f = fs::File::create(&out_path)?;
832 write_csv(&matches, &mut f)?;
833 log::info(&format!(
834 " Wrote {} ({} cue(s))",
835 out_path.display(),
836 matches.len()
837 ));
838 }
839
840 if let Some(hints) = &a.emit_hints {
841 let hints_path = if hints.is_empty() {
842 workspace
843 .join("reports")
844 .join(format!("sfx_match_hints_{date}.md"))
845 } else {
846 PathBuf::from(hints)
847 };
848 if let Some(parent) = hints_path.parent() {
849 fs::create_dir_all(parent)?;
850 }
851 fs::write(&hints_path, render_hints(&matches, &workspace))?;
852 let msg = format!("Wrote {}", hints_path.display());
853 if to_stdout {
854 eprintln!("{msg}");
855 } else {
856 log::info(&format!(" {msg}"));
857 }
858 }
859
860 if a.apply {
861 let accepted: &[&str] = if a.accept_review {
862 &["EXACT", "STRONG", "REVIEW"]
863 } else {
864 &["EXACT", "STRONG"]
865 };
866 let todo: Vec<&CueMatch> = matches
867 .iter()
868 .filter(|m| accepted.contains(&m.tier) && m.best().is_some())
869 .collect();
870 log::info("");
871 log::info(&format!(
872 " {}Applying {} match(es) [{}]:",
873 if a.dry_run { "[dry-run] " } else { "" },
874 todo.len(),
875 accepted.join(", ")
876 ));
877 for m in &todo {
878 apply_match(m, &workspace, a.dry_run)?;
879 }
880 if a.dry_run && !todo.is_empty() {
881 log::info(" Re-run without --dry-run to apply.");
882 }
883 }
884
885 if !a.quiet && !to_stdout {
886 log_summary(&matches, configs_scanned);
887 }
888 Ok(0)
889}
890
891pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
892 log::init("sfx-match");
893 let a: Args = match super::parse_or_exit("xil-sfx-match", args) {
894 Ok(a) => a,
895 Err(code) => return Ok(code),
896 };
897 if a.output.as_deref() == Some("-") {
899 execute(&a)
900 } else {
901 let _banner = banner::begin(SCRIPT_NAME, &super::argv_line(args));
902 execute(&a)
903 }
904}
905
906pub fn command() -> clap::Command {
908 <Args as clap::CommandFactory>::command()
909}
910
911#[cfg(test)]
912mod tests {
913 use super::*;
914
915 fn asset(filename: &str, scope: &str, title: &str, dur: Option<f64>) -> PoolAsset {
916 let mut rec = Map::new();
917 rec.insert("filename".into(), filename.into());
918 rec.insert("path".into(), format!("/ws/SFX/{scope}/{filename}").into());
919 rec.insert("show".into(), scope.into());
920 rec.insert("title".into(), title.into());
921 rec.insert("prompt".into(), "".into());
922 rec.insert("duration_seconds".into(), dur.map_or(Value::Null, py_float));
923 asset_from_record(&rec)
924 }
925
926 #[test]
927 fn tokens_drop_stopwords_and_fold_plurals() {
928 let t: Vec<String> = tokenize("SFX: CHAIRS — the Glass, KEYS up")
929 .into_iter()
930 .collect();
931 assert_eq!(t, vec!["chair", "glass", "key", "up"]);
932 assert!(tokenize("NEW STEM NEEDED: x.mp3").contains("x"));
933 }
934
935 #[test]
936 fn filename_placement_matches_export_kit_buckets() {
937 assert_eq!(placement_from_filename("AMBGras-x.mp3"), "AMBI(bg)");
938 assert_eq!(placement_from_filename("amb1.mp3"), "SFX(fg)");
939 assert_eq!(placement_from_filename("MUSFolk-theme.mp3"), "MUSIC(bg)");
940 assert_eq!(placement_from_filename("beat_x.mp3"), "BEAT(fg)");
941 }
942
943 #[test]
944 fn pool_dedupes_by_tokens_and_duration_preferring_own_show() {
945 let assets = vec![
946 asset("sfx_door.mp3", "", "SFX: DOOR", Some(1.0)),
947 asset("sfx_door.mp3", "myshow", "SFX: DOOR", Some(1.0)),
948 asset("SFX-_DOORS.mp3", "other", "SFX: DOORS", Some(1.0)),
949 asset("sfx_door.mp3", "", "SFX: DOOR", Some(2.0)),
950 ];
951 let pool = build_pool(&assets, Some("myshow"));
952 assert_eq!(pool.len(), 2);
953 assert_eq!(pool[0].scope, "myshow");
954 assert_eq!(pool[0].also_in, vec!["SFX/", "other"]);
955 let idx = build_exact_index(&assets, Some("myshow"));
956 assert_eq!(idx["sfx_door.mp3"].scope, "myshow");
957 }
958
959 #[test]
960 fn tiers_follow_thresholds_and_margin() {
961 let assets = vec![
962 asset("sfx_phone-buzz.mp3", "", "SFX: PHONE BUZZ", Some(1.0)),
963 asset(
964 "sfx_phone-buzz-twice.mp3",
965 "",
966 "SFX: PHONE BUZZ TWICE",
967 Some(2.0),
968 ),
969 asset("a.mp3", "", "AMBIENCE: HEAVY RAIN ON A WINDOW", Some(2.0)),
970 ];
971 let pool = build_pool(&assets, None);
972 let idx = build_exact_index(&assets, None);
973 assert_eq!(match_cue("SFX: PHONE BUZZ", &pool, &idx, 3, 0.5).0, "EXACT");
974 let (t, c) = match_cue("SFX: PHONE BUZZ LOUD", &pool, &idx, 3, 0.5);
975 assert_eq!(t, "REVIEW");
976 assert_eq!(c[0].asset.filename, "sfx_phone-buzz.mp3");
977 assert_eq!(
978 match_cue("AMBIENCE: RAIN ON WINDOW", &pool, &idx, 3, 0.5).0,
979 "STRONG"
980 );
981 assert_eq!(
982 match_cue("MUSIC: RAIN ON WINDOW", &pool, &idx, 3, 0.5).0,
983 "NONE"
984 );
985 }
986}