1use 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 #[arg(long)]
32 episode: Option<String>,
33 #[arg(long)]
35 tag: Option<String>,
36 #[arg(long)]
38 show: Option<String>,
39 #[arg(long)]
41 output: Option<String>,
42 #[arg(long)]
44 parsed: Option<String>,
45 #[arg(long, default_value_t = SILENCE_GAP_MS, allow_negative_numbers = true)]
47 gap_ms: i64,
48}
49
50fn 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
58fn 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
71fn 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
116fn 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
228pub fn command() -> clap::Command {
230 <Args as clap::CommandFactory>::command()
231}