1use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::Value;
10use xil_audio::segment::Segment;
11use xil_audio::tags::tag_mp3;
12use xil_core::workspace::{derive_paths, resolve_slug, workspace_root};
13use xil_core::{banner, log};
14
15use crate::mix::config::CastConfig;
16
17const LAYER_SUFFIXES: [&str; 5] = ["dialogue", "ambience", "music", "sfx", "vintage_filter"];
18const SAMPLE_RATE: u32 = 48000;
19
20#[derive(Parser)]
21#[command(
22 name = "xil-master",
23 about = "Final Master MP3 Export — mix DAW layers into a single podcast-ready MP3"
24)]
25#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
26struct Args {
27 #[arg(long)]
29 episode: Option<String>,
30 #[arg(long)]
32 tag: Option<String>,
33 #[arg(long)]
35 show: Option<String>,
36 #[arg(long)]
38 daw_dir: Option<String>,
39 #[arg(long)]
41 output: Option<String>,
42 #[arg(long)]
44 dry_run: bool,
45}
46
47fn find_cover_art(slug: &str) -> Option<PathBuf> {
49 let dir = workspace_root().join("configs").join(slug);
50 [
51 "cover_art.PNG",
52 "cover_art.png",
53 "cover_art.jpg",
54 "cover_art.jpeg",
55 ]
56 .iter()
57 .map(|n| dir.join(n))
58 .find(|p| p.exists())
59}
60
61fn load_layer_wavs(daw_dir: &str, tag: &str) -> Vec<(&'static str, String)> {
63 LAYER_SUFFIXES
64 .iter()
65 .map(|s| (*s, Path::new(daw_dir).join(format!("{tag}_layer_{s}.wav"))))
66 .filter(|(_, p)| p.exists())
67 .map(|(s, p)| (s, p.to_string_lossy().into_owned()))
68 .collect()
69}
70
71fn mix_layers(layers: &[(&str, String)]) -> anyhow::Result<Segment> {
73 let mut combined: Option<Segment> = None;
74 for (_, path) in layers {
75 let seg = Segment::from_file(Path::new(path))?;
76 combined = Some(match combined {
77 None => seg,
78 Some(c) => c.overlay(seg, 0),
79 });
80 }
81 Ok(combined.expect("at least one layer"))
82}
83
84pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
85 log::init("master");
86 let _banner = banner::begin(super::prog(), &super::argv_line(args));
87 let a: Args = match super::parse_or_exit("xil-master", args) {
88 Ok(a) => a,
89 Err(code) => return Ok(code),
90 };
91 execute(&a)?;
92 Ok(0)
93}
94
95fn execute(a: &Args) -> anyhow::Result<()> {
96 let slug = resolve_slug(a.show.as_deref(), "project.json");
97 let tag = a
98 .episode
99 .clone()
100 .or_else(|| a.tag.clone())
101 .unwrap_or_default();
102 let p = derive_paths(&slug, &tag);
103 let daw_dir = a
104 .daw_dir
105 .clone()
106 .filter(|s| !s.is_empty())
107 .unwrap_or_else(|| p["daw"].to_string_lossy().into_owned());
108
109 let (mut show_name, mut episode_title, mut artist) =
110 (None::<String>, None::<String>, None::<String>);
111 if p["cast"].exists() {
112 let cast = CastConfig::load(&p["cast"])?;
113 show_name = Some(cast.show);
114 episode_title = cast.title;
115 artist = Some(cast.artist);
116 }
117 if (show_name.is_none() || episode_title.is_none()) && p["parsed"].exists() {
118 let parsed: Value = serde_json::from_str(&fs::read_to_string(&p["parsed"])?)?;
119 let field = |k: &str| match parsed.get(k) {
120 Some(Value::Null) | None => None,
121 Some(Value::String(s)) => Some(s.clone()),
122 Some(other) => Some(other.to_string()),
123 };
124 if show_name.is_none() {
125 show_name = field("show");
126 }
127 if episode_title.is_none() {
128 episode_title = field("title");
129 }
130 let truthy = |v: &Option<String>| v.as_ref().is_some_and(|s| !s.is_empty());
131 if truthy(&show_name) || truthy(&episode_title) {
132 log::info(" Metadata sourced from parsed JSON (cast config fields absent)");
133 }
134 }
135 let show_name = show_name
136 .filter(|s| !s.is_empty())
137 .unwrap_or_else(|| "Sample Show".into());
138 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
139
140 let output_path = match a.output.as_ref().filter(|s| !s.is_empty()) {
141 Some(o) => o.clone(),
142 None => {
143 let masters = workspace_root().join("masters");
144 fs::create_dir_all(&masters)?;
145 masters
146 .join(format!("{tag}_{slug}_{today}.mp3"))
147 .to_string_lossy()
148 .into_owned()
149 }
150 };
151
152 let layers = load_layer_wavs(&daw_dir, &tag);
153 let missing: Vec<&str> = LAYER_SUFFIXES
154 .iter()
155 .copied()
156 .filter(|s| !layers.iter().any(|(n, _)| n == s))
157 .collect();
158
159 log::info(&format!(" Episode : {tag}"));
160 log::info(&format!(" Show : {show_name} (slug: {slug})"));
161 log::info(&format!(" DAW dir : {daw_dir}"));
162 log::info(&format!(" Output : {output_path}"));
163 log::info(&format!(
164 " Format : Stereo, {SAMPLE_RATE} Hz, VBR MP3 (~145-185 kbps)"
165 ));
166 log::info(&format!(
167 " Layers : {}/{} found",
168 layers.len(),
169 LAYER_SUFFIXES.len()
170 ));
171 for (name, path) in &layers {
172 log::info(&format!(" [{name:>9}] {path}"));
173 }
174 if !missing.is_empty() {
175 log::info(&format!(" Missing : {}", missing.join(", ")));
176 }
177 log::info("");
178
179 if layers.is_empty() {
180 log::warning("No layer WAVs found. Run XILP005 first.");
181 return Ok(());
182 }
183 if a.dry_run {
184 log::info("--- Dry run — no files written ---");
185 return Ok(());
186 }
187
188 log::info("--- Mixing layers ---");
189 let combined = mix_layers(&layers)?;
190 let duration_s = combined.len_ms() as f64 / 1000.0;
191 let minutes = (duration_s / 60.0).floor() as i64;
192 let seconds = duration_s.rem_euclid(60.0);
193 log::info(&format!(" Duration : {minutes}:{seconds:05.2}"));
194 log::info("--- Exporting master MP3 ---");
195
196 let title = match episode_title.as_deref().filter(|t| !t.is_empty()) {
197 Some(t) => format!("{show_name} — {t}"),
198 None => tag.clone(),
199 };
200 let cover = find_cover_art(&slug);
201 if let Some(c) = &cover {
202 log::info(&format!(" Cover art : {}", c.display()));
203 }
204
205 let out = Path::new(&output_path);
207 fs::File::create(out)?;
208 let mut combined = combined;
209 if combined.channels == 1 {
210 combined = combined.set_channels(2);
211 }
212 let combined = combined.set_frame_rate(SAMPLE_RATE);
213 combined.export_ffmpeg(out, "mp3", &["-q:a", "2", "-ar", "48000"])?;
214 drop(combined);
215 let title_or_tag = if title.is_empty() { tag.clone() } else { title };
216 tag_mp3(
217 out,
218 &show_name,
219 Some(&title_or_tag),
220 artist.as_deref(),
221 cover.as_deref(),
222 )?;
223
224 let size_mb = fs::metadata(out)?.len() as f64 / (1024.0 * 1024.0);
225 log::info(&format!(" Written : {output_path} ({size_mb:.1} MB)"));
226 log::info("--- Done! ---");
227 Ok(())
228}
229
230pub fn command() -> clap::Command {
232 <Args as clap::CommandFactory>::command()
233}