1use std::collections::HashSet;
5use std::ffi::OsString;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use xil_core::workspace::{derive_paths, resolve_slug, show_slug, workspace_root};
10use xil_core::{banner, log};
11
12use crate::mix::config::{CastConfig, SfxConfig};
13use crate::sfxgen;
14
15#[derive(Parser)]
16#[command(
17 name = "xil-sfx",
18 about = "Generate SFX stems from an SFX config (standalone utility)"
19)]
20#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
21struct Args {
22 #[arg(long)]
24 episode: Option<String>,
25 #[arg(long)]
27 tag: Option<String>,
28 #[arg(long)]
30 show: Option<String>,
31 #[arg(long)]
33 script: Option<String>,
34 #[arg(long)]
36 dry_run: bool,
37 #[arg(long, allow_negative_numbers = true)]
39 max_duration: Option<f64>,
40 #[arg(long)]
42 gen_sfx: bool,
43 #[arg(long)]
45 gen_music: bool,
46 #[arg(long)]
48 gen_ambience: bool,
49 #[arg(long)]
51 sfx_music: bool,
52 #[arg(long)]
54 local_only: bool,
55 #[arg(long, default_value = "elevenlabs", value_parser = ["elevenlabs", "mmaudio"], value_name = "BACKEND")]
57 sfx_backend: String,
58 #[arg(long, value_name = "PATH")]
60 mmaudio_python: Option<String>,
61 #[arg(
63 long,
64 default_value_t = 8.0,
65 value_name = "FLOAT",
66 allow_negative_numbers = true
67 )]
68 mmaudio_duration: f64,
69 #[arg(
71 long,
72 default_value_t = 4.5,
73 value_name = "FLOAT",
74 allow_negative_numbers = true
75 )]
76 mmaudio_cfg: f64,
77 #[arg(
79 long,
80 default_value_t = 25,
81 value_name = "INT",
82 allow_negative_numbers = true
83 )]
84 mmaudio_steps: i64,
85 #[arg(long, default_value = "", value_name = "STR")]
87 mmaudio_negative_prompt: String,
88 #[arg(long, value_name = "INT", allow_negative_numbers = true)]
90 mmaudio_seed: Option<i64>,
91 #[arg(long)]
93 mmaudio_accept_noncommercial: bool,
94}
95
96fn exit_with(msg: &str) -> anyhow::Result<i32> {
97 Err(super::SysExit(msg.to_string()).into())
98}
99
100pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
101 log::init("sfx");
102 let result = {
103 let _banner = banner::begin(super::prog(), &super::argv_line(args));
104 execute(args)
105 };
106 super::finish(result)
107}
108
109fn execute(args: &[OsString]) -> anyhow::Result<i32> {
110 let a: Args = match super::parse_or_exit("xil-sfx", args) {
111 Ok(a) => a,
112 Err(code) => return Ok(code),
113 };
114 let key = std::env::var("ELEVENLABS_API_KEY")
115 .ok()
116 .filter(|k| !k.is_empty());
117 if !a.dry_run && a.sfx_backend == "elevenlabs" && key.is_none() {
118 return exit_with("Error: ELEVENLABS_API_KEY environment variable is not set.");
119 }
120 let tag = a
121 .episode
122 .clone()
123 .or_else(|| a.tag.clone())
124 .unwrap_or_default();
125 let slug = resolve_slug(a.show.as_deref(), "project.json");
126 let p = derive_paths(&slug, &tag);
127 let cast_path = p["cast"].clone();
128 let sfx_path = p["sfx"].clone();
129
130 let script: PathBuf = match &a.script {
131 Some(s) => PathBuf::from(s),
132 None => {
133 if !cast_path.exists() {
134 return exit_with(&format!(
135 "Error: Cast config not found: {}\nRun XILP001 first or check your --episode flag.",
136 cast_path.display()
137 ));
138 }
139 CastConfig::load(&cast_path)?;
140 p["parsed"].clone()
141 }
142 };
143
144 let direction_types: Option<HashSet<&str>> =
145 if a.gen_sfx || a.gen_music || a.gen_ambience || a.sfx_music {
146 let mut t = HashSet::new();
147 if a.gen_sfx || a.sfx_music {
148 t.insert("SFX");
149 t.insert("BEAT");
150 }
151 if a.gen_music || a.sfx_music {
152 t.insert("MUSIC");
153 }
154 if a.gen_ambience || a.sfx_music {
155 t.insert("AMBIENCE");
156 }
157 Some(t)
158 } else {
159 None
160 };
161
162 if !cast_path.exists() {
164 anyhow::bail!(
165 "FileNotFoundError: Cast config not found: {}\nRun XILP001 first or check your --episode flag.",
166 cast_path.display()
167 );
168 }
169 let cast = CastConfig::load(&cast_path)?;
170 let stems_dir = workspace_root()
171 .join("stems")
172 .join(show_slug(&cast.show))
173 .join(&cast.tag);
174 let sfx_dir = workspace_root().join("SFX");
175 if !Path::new(&script).exists() {
176 anyhow::bail!(
177 "FileNotFoundError: [Errno 2] No such file or directory: '{}'",
178 script.display()
179 );
180 }
181 if !sfx_path.exists() {
182 anyhow::bail!(
183 "FileNotFoundError: [Errno 2] No such file or directory: '{}'",
184 sfx_path.display()
185 );
186 }
187 let cfg = SfxConfig::load(&sfx_path)?;
188 let entries = sfxgen::load_sfx_entries(
189 &script,
190 &cfg,
191 a.max_duration,
192 direction_types.as_ref(),
193 a.local_only,
194 &sfx_dir,
195 )?;
196
197 if a.dry_run {
198 sfxgen::dry_run_sfx(&entries, &cfg, &stems_dir, &sfx_dir, &a.sfx_backend);
199 return Ok(0);
200 }
201 let mm = sfxgen::MMAudioOptions {
202 python: a.mmaudio_python.as_deref(),
203 cfg: a.mmaudio_cfg,
204 steps: a.mmaudio_steps,
205 negative_prompt: &a.mmaudio_negative_prompt,
206 seed: a.mmaudio_seed,
207 duration: a.mmaudio_duration,
208 accept_noncommercial: a.mmaudio_accept_noncommercial,
209 };
210 let Some(mut backend) =
211 sfxgen::make_sfx_backend(&a.sfx_backend, xil_api::elevenlabs::Client::new(key), &mm)?
212 else {
213 return Ok(1);
214 };
215 let result = sfxgen::generate_sfx(&entries, &cfg, &stems_dir, &sfx_dir, 1, backend.as_mut());
216 backend.close()?;
217 result?;
218 Ok(0)
219}
220
221pub fn command() -> clap::Command {
223 <Args as clap::CommandFactory>::command()
224}