Skip to main content

xil/cmd/
sfx.rs

1//! `xil sfx` — generate SFX / music / ambience stems from an episode's SFX
2//! config, standalone from the producer. Port of `XILU002_generate_SFX.py`.
3
4use 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    /// Episode tag (e.g. S01E01) — derives cast and SFX config paths
23    #[arg(long)]
24    episode: Option<String>,
25    /// Raw tag for non-episodic content (e.g. V01C03, D01)
26    #[arg(long)]
27    tag: Option<String>,
28    /// Show name override (default: from project.json)
29    #[arg(long)]
30    show: Option<String>,
31    /// Path to parsed script JSON (default: derived from cast config)
32    #[arg(long)]
33    script: Option<String>,
34    /// Preview existing vs. new stems and estimated credit cost
35    #[arg(long)]
36    dry_run: bool,
37    /// Only process effects with duration_seconds <= this value
38    #[arg(long, allow_negative_numbers = true)]
39    max_duration: Option<f64>,
40    /// Limit to SFX and BEAT entries only
41    #[arg(long)]
42    gen_sfx: bool,
43    /// Limit to MUSIC entries only
44    #[arg(long)]
45    gen_music: bool,
46    /// Limit to AMBIENCE entries only
47    #[arg(long)]
48    gen_ambience: bool,
49    /// (deprecated) shorthand for --gen-sfx --gen-music --gen-ambience
50    #[arg(long)]
51    sfx_music: bool,
52    /// Only place stems for effects already present in SFX/; skip API generation
53    #[arg(long)]
54    local_only: bool,
55    /// Backend for SFX/music/ambience generation, independent of the dialogue --backend. 'elevenlabs' (default) calls the ElevenLabs Sound Effects API. 'mmaudio' runs MMAudio locally in venv-mmaudio — free and GPU-accelerated, writes backend-tagged assets to SFX/<slug>.mmaudio.mp3. MMAudio's weights are CC BY-NC 4.0 (non-commercial only) and require --mmaudio-accept-noncommercial.
56    #[arg(long, default_value = "elevenlabs", value_parser = ["elevenlabs", "mmaudio"], value_name = "BACKEND")]
57    sfx_backend: String,
58    /// Path to the Python executable in the MMAudio venv (default: auto-detect ./venv-mmaudio/bin/python3). Used only with --sfx-backend mmaudio.
59    #[arg(long, value_name = "PATH")]
60    mmaudio_python: Option<String>,
61    /// Generation length in seconds before trimming (default: 8.0, MMAudio's training duration). The result is trimmed to each cue's duration_seconds; generating at the native length and trimming gives better audio than asking for a short clip.
62    #[arg(
63        long,
64        default_value_t = 8.0,
65        value_name = "FLOAT",
66        allow_negative_numbers = true
67    )]
68    mmaudio_duration: f64,
69    /// MMAudio classifier-free guidance strength (default: 4.5).
70    #[arg(
71        long,
72        default_value_t = 4.5,
73        value_name = "FLOAT",
74        allow_negative_numbers = true
75    )]
76    mmaudio_cfg: f64,
77    /// MMAudio flow-matching sampling steps (default: 25).
78    #[arg(
79        long,
80        default_value_t = 25,
81        value_name = "INT",
82        allow_negative_numbers = true
83    )]
84    mmaudio_steps: i64,
85    /// Optional MMAudio negative prompt (default: none).
86    #[arg(long, default_value = "", value_name = "STR")]
87    mmaudio_negative_prompt: String,
88    /// MMAudio reproducibility seed (default: nondeterministic).
89    #[arg(long, value_name = "INT", allow_negative_numbers = true)]
90    mmaudio_seed: Option<i64>,
91    /// 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.
92    #[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    // load_sfx_plan: the cast config names the stems directory.
163    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
221/// The clap definition behind `--help`, for man pages.
222pub fn command() -> clap::Command {
223    <Args as clap::CommandFactory>::command()
224}