Skip to main content

xil/cmd/
assemble.rs

1//! `xil assemble` — mix stems straight to a master MP3: a two-pass
2//! foreground/background mix when a parsed script exists, a plain
3//! sequential join otherwise. Port of `XILP003_audio_assembly.py`.
4
5use std::ffi::OsString;
6use std::fs;
7use std::path::Path;
8use std::process::Command;
9
10use anyhow::anyhow;
11use clap::Parser;
12use indexmap::IndexMap;
13use xil_audio::segment::Segment;
14use xil_core::fsutil::abspath;
15use xil_core::workspace::{derive_paths, resolve_slug, workspace_root};
16use xil_core::{banner, log};
17
18use crate::mix::config::{CastConfig, SfxConfig, Voice};
19use crate::mix::{self, AMBIENCE_LEVEL_DB, MUSIC_LEVEL_DB};
20
21const SILENCE_GAP_MS: i64 = 600;
22
23#[derive(Parser)]
24#[command(
25    name = "xil-assemble",
26    about = "Audio Assembly — assemble voice stems into master MP3"
27)]
28#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
29struct Args {
30    /// Episode tag (e.g. S01E01) — derives cast 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    /// Output master MP3 path (default: <slug>_<TAG>_master.mp3)
40    #[arg(long)]
41    output: Option<String>,
42    /// Path to parsed script JSON (default: parsed/parsed_<slug>_<TAG>.json)
43    #[arg(long)]
44    parsed: Option<String>,
45    /// Silence gap between foreground stems in ms (default: 600)
46    #[arg(long, default_value_t = SILENCE_GAP_MS, allow_negative_numbers = true)]
47    gap_ms: i64,
48}
49
50/// `export(final_output, format="mp3")` — pydub opens the destination
51/// before encoding, so an unwritable path fails first.
52fn export_mp3(seg: &Segment, out: &str) -> anyhow::Result<()> {
53    fs::File::create(out)?;
54    seg.export_ffmpeg(Path::new(out), "mp3", &[])?;
55    Ok(())
56}
57
58/// `subprocess.run(["mpg123", abspath(out)], check=False)` — a missing
59/// player raises `FileNotFoundError` after the file is written.
60fn play(out: &str) -> anyhow::Result<()> {
61    let abs = abspath(Path::new(out));
62    match Command::new("mpg123").arg(&abs).status() {
63        Ok(_) => Ok(()),
64        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(anyhow!(
65            "FileNotFoundError: [Errno 2] No such file or directory: 'mpg123'"
66        )),
67        Err(e) => Err(e.into()),
68    }
69}
70
71/// `assemble_audio` — the sequential fallback.
72fn assemble_audio(
73    cast: &IndexMap<String, Voice>,
74    stems_dir: &str,
75    out: &str,
76    gap_ms: i64,
77) -> anyhow::Result<()> {
78    let mut stems: Vec<String> = xil_core::fsutil::glob_children(Path::new(stems_dir), "", ".mp3")
79        .into_iter()
80        .map(|p| p.to_string_lossy().into_owned())
81        .collect();
82    stems.sort();
83    if stems.is_empty() {
84        log::warning(&format!(
85            "No stems found in {stems_dir}/. Run XILP002 first."
86        ));
87        return Ok(());
88    }
89    log::info(&format!(
90        "--- Phase 2: Assembling {} stems (sequential) ---",
91        stems.len()
92    ));
93    let mut full = Segment::empty();
94    for stem in &stems {
95        let speaker = mix::stem_basename(stem)
96            .rsplit('_')
97            .next()
98            .unwrap_or("")
99            .to_string();
100        log::info(&format!("   Loading: {stem} ({speaker})"));
101        let mut segment = Segment::from_file(Path::new(stem))?;
102        if let Some(v) = cast.get(&speaker) {
103            segment = mix::apply_speaker_filters(segment, &v.filter)?;
104            segment = segment.pan(v.pan);
105        }
106        full = full.append(segment.append(Segment::silent(gap_ms as f64)));
107    }
108    export_mp3(&full, out)?;
109    log::info(&format!(
110        "--- Success! Created: {out} (Duration: {:.1}s) ---",
111        full.len_ms() as f64 / 1000.0
112    ));
113    play(out)
114}
115
116/// `assemble_multitrack` — foreground plus ducked ambience and music.
117fn assemble_multitrack(
118    cast: &IndexMap<String, Voice>,
119    stems_dir: &str,
120    parsed_path: &Path,
121    out: &str,
122    sfx: Option<&SfxConfig>,
123    gap_ms: i64,
124) -> anyhow::Result<()> {
125    let index = mix::load_entries_index(parsed_path)?;
126    let plans = mix::collect_stem_plans(Path::new(stems_dir), &index, sfx);
127    if plans.is_empty() {
128        log::warning(&format!(
129            "No stems found in {stems_dir}/. Run XILP002 first."
130        ));
131        return Ok(());
132    }
133    log::info(&format!(
134        "--- Phase 2: Assembling {} stems (multi-track) ---",
135        plans.len()
136    ));
137    let vintage_scenes: Vec<String> = sfx.map(|s| s.vintage_scenes.clone()).unwrap_or_default();
138    let (foreground, timeline) = mix::build_foreground(&plans, cast, gap_ms, &vintage_scenes)?;
139    if foreground.len_ms() == 0 {
140        log::warning("No foreground stems found — only background stems present.");
141        return Ok(());
142    }
143    let total_ms = foreground.len_ms();
144    let bg = plans.iter().filter(|p| p.is_background()).count();
145    let master = if bg > 0 {
146        log::info(&format!(
147            "   Mixing {bg} background stems (ambience/music)..."
148        ));
149        let (ambience, _) =
150            mix::build_ambience_layer(&plans, &timeline, total_ms, AMBIENCE_LEVEL_DB)?;
151        let (music, _) =
152            mix::build_music_layer(&plans, &timeline, total_ms, MUSIC_LEVEL_DB, false)?;
153        let background = ambience.overlay(music, 0);
154        foreground.overlay(background, 0)
155    } else {
156        log::info("   No background stems found — skipping overlay pass.");
157        foreground
158    };
159    export_mp3(&master, out)?;
160    log::info(&format!(
161        "--- Success! Created: {out} (Duration: {:.1}s) ---",
162        master.len_ms() as f64 / 1000.0
163    ));
164    play(out)
165}
166
167pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
168    log::init("assemble");
169    let _banner = banner::begin(super::prog(), &super::argv_line(args));
170    let a: Args = match super::parse_or_exit("xil-assemble", args) {
171        Ok(a) => a,
172        Err(code) => return Ok(code),
173    };
174    execute(&a)?;
175    Ok(0)
176}
177
178fn execute(a: &Args) -> anyhow::Result<()> {
179    let arg_tag = a
180        .episode
181        .clone()
182        .or_else(|| a.tag.clone())
183        .unwrap_or_default();
184    let slug = resolve_slug(a.show.as_deref(), "project.json");
185    let p = derive_paths(&slug, &arg_tag);
186    if !p["cast"].exists() {
187        log::error(&format!("Cast config not found: {}", p["cast"].display()));
188        log::info("Run XILP001 first or check your --episode flag.");
189        return Ok(());
190    }
191    let cast = CastConfig::load(&p["cast"])?;
192    let stems_dir = mix::join(&workspace_root().join("stems").join(&slug), &cast.tag);
193    let output = a
194        .output
195        .clone()
196        .filter(|s| !s.is_empty())
197        .unwrap_or_else(|| p["master"].to_string_lossy().into_owned());
198    let parsed_path = a
199        .parsed
200        .clone()
201        .filter(|s| !s.is_empty())
202        .map(Into::into)
203        .unwrap_or_else(|| p["parsed"].clone());
204    let sfx = if p["sfx"].exists() {
205        Some(SfxConfig::load(&p["sfx"])?)
206    } else {
207        None
208    };
209
210    if parsed_path.exists() {
211        assemble_multitrack(
212            &cast.cast,
213            &stems_dir,
214            &parsed_path,
215            &output,
216            sfx.as_ref(),
217            a.gap_ms,
218        )
219    } else {
220        log::info(&format!(
221            "   No parsed JSON at {} — using sequential assembly.",
222            xil_audio::fx::py_repr(&parsed_path.to_string_lossy())
223        ));
224        assemble_audio(&cast.cast, &stems_dir, &output, a.gap_ms)
225    }
226}
227
228/// The clap definition behind `--help`, for man pages.
229pub fn command() -> clap::Command {
230    <Args as clap::CommandFactory>::command()
231}