Skip to main content

xil/cmd/
master.rs

1//! `xil master` — overlay the DAW layer WAVs into one stereo 48 kHz VBR
2//! MP3 with ID3 tags and cover art. Port of `XILP011_master_export.py`.
3
4use 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    /// Episode tag (e.g. S02E03) — derives DAW layer paths
28    #[arg(long)]
29    episode: Option<String>,
30    /// Raw tag for non-episodic content (e.g. V01C03, D01)
31    #[arg(long)]
32    tag: Option<String>,
33    /// Show name override (default: from project.json)
34    #[arg(long)]
35    show: Option<String>,
36    /// DAW layer directory (default: daw/<TAG>/)
37    #[arg(long)]
38    daw_dir: Option<String>,
39    /// Output MP3 path (default: masters/<TAG>_<slug>_<date>.mp3)
40    #[arg(long)]
41    output: Option<String>,
42    /// Show what would be exported without writing files
43    #[arg(long)]
44    dry_run: bool,
45}
46
47/// `_find_cover_art(slug)`.
48fn 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
61/// `load_layer_wavs(daw_dir, tag)`.
62fn 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
71/// `mix_layers(layer_paths)`.
72fn 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    // export_master: stereo, 48 kHz, LAME VBR q2, then ID3.
206    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
230/// The clap definition behind `--help`, for man pages.
231pub fn command() -> clap::Command {
232    <Args as clap::CommandFactory>::command()
233}