Skip to main content

xil/cmd/
daw.rs

1//! `xil daw` — export an episode as five aligned DAW layer WAVs, Audacity
2//! label tracks, an import helper script and a timeline. Port of
3//! `XILP005_daw_export.py`.
4
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use anyhow::bail;
11use clap::Parser;
12use indexmap::IndexMap;
13use serde_json::{Map, Value};
14use xil_audio::tags::tag_wav;
15use xil_core::fsutil::abspath;
16use xil_core::workspace::{derive_paths, resolve_slug, show_slug, workspace_root, DEFAULT_SLUG};
17use xil_core::{banner, log};
18
19use crate::mix::config::{CastConfig, SfxConfig, Voice};
20use crate::mix::timeline::{
21    build_timeline_data, render_html_timeline, render_terminal_timeline, render_text_timeline_map,
22};
23use crate::mix::{self, Label, StemPlan};
24
25const SILENCE_GAP_MS: i64 = 600;
26
27const SCRIPT_TEMPLATE: &str = include_str!("../mix/templates/open_in_audacity.py.tmpl");
28const SCRIPT_TEMPLATE_AUP3: &str = include_str!("../mix/templates/open_in_audacity_aup3.py.tmpl");
29
30/// `(key, filename suffix, description)` for each layer.
31const LAYERS: [(&str, &str, &str); 5] = [
32    (
33        "dialogue",
34        "layer_dialogue",
35        "Spoken dialogue (audio filter chain + pan applied per speaker)",
36    ),
37    (
38        "ambience",
39        "layer_ambience",
40        "Looped environmental background (no ducking)",
41    ),
42    (
43        "music",
44        "layer_music",
45        "Music stings and themes (no ducking)",
46    ),
47    (
48        "sfx",
49        "layer_sfx",
50        "One-shot sound effects and beat silences",
51    ),
52    (
53        "vintage_filter",
54        "layer_vintage_filter",
55        "Record player crackle (vintage filter active spans)",
56    ),
57];
58
59#[derive(Parser)]
60#[command(
61    name = "xil-daw",
62    about = "DAW Export — export episode as layered WAV files for Audacity"
63)]
64#[command(group(clap::ArgGroup::new("tag_group").required(true).args(["episode", "tag"])))]
65struct Args {
66    /// Episode tag (e.g. S01E02) — derives cast config, stems, and parsed JSON paths
67    #[arg(long)]
68    episode: Option<String>,
69    /// Raw tag for non-episodic content (e.g. V01C03, D01)
70    #[arg(long)]
71    tag: Option<String>,
72    /// Show name override (default: from project.json)
73    #[arg(long)]
74    show: Option<String>,
75    /// Path to parsed script JSON (default: parsed/parsed_<slug>_<TAG>.json)
76    #[arg(long)]
77    parsed: Option<String>,
78    /// Output directory for layer WAVs (default: daw/<TAG>/)
79    #[arg(long)]
80    output_dir: Option<String>,
81    /// Show export summary without writing files
82    #[arg(long)]
83    dry_run: bool,
84    /// Include SaveProject2 step in the Audacity helper script (requires mod-script-pipe; Audacity 3 only)
85    #[arg(long)]
86    save_aup3: bool,
87    /// Write an Audacity macro to %APPDATA%\audacity\Macros\ for one-click import (Audacity 3 only — Audacity 4 has no Macro Manager)
88    #[arg(long = "macro")]
89    macro_: bool,
90    /// Print an ASCII timeline visualization of asset placement to stdout
91    #[arg(long)]
92    timeline: bool,
93    /// Write an interactive HTML timeline to daw/<TAG>/<TAG>_timeline.html
94    #[arg(long)]
95    timeline_html: bool,
96    /// Silence gap between foreground stems in ms (default: 600)
97    #[arg(long, default_value_t = SILENCE_GAP_MS, allow_negative_numbers = true)]
98    gap_ms: i64,
99}
100
101/// `_validate_tag_for_script` — tags are pasted into generated Python.
102fn validate_tag_for_script(tag: &str) -> anyhow::Result<()> {
103    let ok = !tag.is_empty()
104        && tag
105            .chars()
106            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
107    if !ok {
108        bail!(
109            "Tag {} contains characters that are not safe for script generation. Tags must match ^[A-Za-z0-9_-]+$ (letters, digits, hyphens, underscores only).",
110            xil_audio::fx::py_repr(tag)
111        );
112    }
113    Ok(())
114}
115
116/// `_write_labels`: tab-separated start, end, text.
117fn write_labels(output_dir: &str, fname: &str, labels: &[Label]) -> std::io::Result<()> {
118    let mut out = String::new();
119    for l in labels {
120        out.push_str(&format!("{:.3}\t{:.3}\t{}\n", l.start_s, l.end_s, l.text));
121    }
122    fs::write(Path::new(output_dir).join(fname), out)
123}
124
125fn command_output(program: &str, args: &[&str]) -> Option<String> {
126    let out = Command::new(program)
127        .args(args)
128        .stderr(Stdio::null())
129        .output()
130        .ok()?;
131    if !out.status.success() {
132        return None;
133    }
134    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
135}
136
137/// `_audacity_config_dir`: `%APPDATA%/audacity`, via `cmd.exe` + `wslpath` on WSL.
138fn audacity_config_dir() -> Option<PathBuf> {
139    let appdata = match std::env::var("APPDATA").ok().filter(|v| !v.is_empty()) {
140        Some(a) => a,
141        None => {
142            let win = command_output("cmd.exe", &["/c", "echo %APPDATA%"])?;
143            command_output("wslpath", &["-u", &win])?
144        }
145    };
146    let dir = Path::new(&appdata).join("audacity");
147    dir.is_dir().then_some(dir)
148}
149
150fn find_audacity_macros_dir() -> Option<PathBuf> {
151    let dir = audacity_config_dir()?.join("Macros");
152    dir.is_dir().then_some(dir)
153}
154
155/// `detect_audacity_generations()` — does this machine have Audacity 4 config?
156fn has_audacity4() -> bool {
157    audacity_config_dir()
158        .is_some_and(|d| d.join("Audacity4.ini").exists() || d.join("Audacity4").exists())
159}
160
161fn to_windows_path(linux_path: &str) -> String {
162    command_output("wslpath", &["-w", linux_path]).unwrap_or_else(|| linux_path.to_string())
163}
164
165fn macro_slug(show: &str) -> String {
166    if show.is_empty() {
167        DEFAULT_SLUG.to_uppercase()
168    } else {
169        show_slug(show).to_uppercase()
170    }
171}
172
173/// `generate_audacity_macro(...)` — `None` when there is no Macros directory.
174fn generate_audacity_macro(
175    output_dir: &str,
176    tag: &str,
177    layer_files: &[(String, String)],
178    show: &str,
179    season_title: Option<&str>,
180    episode_title: Option<&str>,
181    artist: &str,
182) -> anyhow::Result<Option<PathBuf>> {
183    let Some(macros_dir) = find_audacity_macros_dir() else {
184        return Ok(None);
185    };
186    let abs_output = abspath(Path::new(output_dir));
187    let mut lines = Vec::new();
188    for (_, filename) in layer_files {
189        if !filename.ends_with(".wav") {
190            continue;
191        }
192        let win = to_windows_path(&abs_output.join(filename).to_string_lossy());
193        lines.push(format!("Import2: Filename=\"{win}\""));
194    }
195    let title = match (
196        season_title.filter(|s| !s.is_empty()),
197        episode_title.filter(|s| !s.is_empty()),
198    ) {
199        (Some(s), Some(e)) => format!("{tag}: {s} - {e}"),
200        (None, Some(e)) => format!("{tag}: {e}"),
201        _ => tag.to_string(),
202    };
203    use chrono::Datelike;
204    let year = chrono::Local::now().year();
205    lines.push(format!(
206        "SetProject: X-Genre=\"Podcast\" X-Album=\"{show}\" X-Artist=\"{artist}\" X-Title=\"{title}\" X-Year=\"{year}\""
207    ));
208    let path = macros_dir.join(format!("{}_{tag}.txt", macro_slug(show)));
209    fs::write(&path, lines.join("\n") + "\n")?;
210    Ok(Some(path))
211}
212
213/// `repr(layer_files)` for a list of `(str, str)` tuples.
214fn layers_repr(layer_files: &[(String, String)]) -> String {
215    let items: Vec<String> = layer_files
216        .iter()
217        .map(|(a, b)| {
218            format!(
219                "({}, {})",
220                xil_audio::fx::py_repr(a),
221                xil_audio::fx::py_repr(b)
222            )
223        })
224        .collect();
225    format!("[{}]", items.join(", "))
226}
227
228/// `_make_audacity_script(tag, layer_files, save_aup3, show)`.
229fn make_audacity_script(
230    tag: &str,
231    layer_files: &[(String, String)],
232    save_aup3: bool,
233    show: &str,
234) -> String {
235    let template = if save_aup3 {
236        SCRIPT_TEMPLATE_AUP3
237    } else {
238        SCRIPT_TEMPLATE
239    };
240    let show_label = if show.is_empty() { "Episode" } else { show };
241    template
242        .replace("@@XIL_LAYERS_REPR@@", &layers_repr(layer_files))
243        .replace("@@XIL_SHOW_LABEL@@", show_label)
244        .replace("@@XIL_TAG@@", tag)
245}
246
247/// `derive_vintage_scenes_from_parsed(parsed_path)`.
248fn derive_vintage_scenes_from_parsed(parsed_path: &Path) -> Vec<String> {
249    let Some(parsed) = fs::read_to_string(parsed_path)
250        .ok()
251        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
252    else {
253        return Vec::new();
254    };
255    let mut scenes: Vec<String> = Vec::new();
256    for e in parsed
257        .get("entries")
258        .and_then(Value::as_array)
259        .into_iter()
260        .flatten()
261    {
262        let Some(o) = e.as_object() else { continue };
263        let s = |k: &str| o.get(k).and_then(Value::as_str);
264        if s("type") == Some("direction")
265            && s("direction_type") == Some("VINTAGE FILTER")
266            && s("text").unwrap_or("").to_uppercase() == "VINTAGE FILTER ENGAGES"
267        {
268            if let Some(scene) = s("scene").filter(|x| !x.is_empty()) {
269                if !scenes.iter().any(|x| x == scene) {
270                    scenes.push(scene.to_string());
271                }
272            }
273        }
274    }
275    scenes
276}
277
278fn fmt_pct_1(v: Option<f64>) -> String {
279    v.map(|x| format!("{x:.1}%"))
280        .unwrap_or_else(|| "100% (full)".into())
281}
282
283fn fmt_vol(v: Option<mix::config::Num>) -> String {
284    v.map(|n| format!("{:.0}%", n.f()))
285        .unwrap_or_else(|| "unity".into())
286}
287
288fn fmt_secs(v: Option<mix::config::Num>) -> String {
289    v.map(|n| format!("{:.1}s", n.f()))
290        .unwrap_or_else(|| "none".into())
291}
292
293fn speaker_filter_lines(cast: &IndexMap<String, Voice>) -> Vec<(String, String)> {
294    cast.iter()
295        .filter(|(_, v)| v.filter.truthy())
296        .map(|(k, v)| (k.clone(), v.filter.py_str()))
297        .collect()
298}
299
300/// `dry_run_daw(...)`.
301fn dry_run_daw(
302    tag: &str,
303    plans: &[StemPlan],
304    output_dir: &str,
305    stems_dir: &str,
306    cast: &IndexMap<String, Voice>,
307    vintage_scenes: &[String],
308) -> anyhow::Result<()> {
309    validate_tag_for_script(tag)?;
310    let bg: Vec<&StemPlan> = plans.iter().filter(|p| p.is_background()).collect();
311    let count = |dt: &str| bg.iter().filter(|p| p.dt() == Some(dt)).count();
312    let sfx = plans
313        .iter()
314        .filter(|p| matches!(p.dt(), Some("SFX") | Some("BEAT")))
315        .count();
316    let dialogue: Vec<&StemPlan> = plans.iter().filter(|p| p.is_dialogue()).collect();
317    let vintage_count = if vintage_scenes.is_empty() {
318        0
319    } else {
320        dialogue
321            .iter()
322            .filter(|p| p.scene.as_ref().is_some_and(|s| vintage_scenes.contains(s)))
323            .count()
324    };
325    let stems_shown = if stems_dir.is_empty() {
326        format!("stems/{tag}")
327    } else {
328        stems_dir.to_string()
329    };
330
331    log::info(&format!("\n--- DAW Export Dry Run: {tag} ---"));
332    log::info(&format!("   Stems directory : {stems_shown}"));
333    log::info(&format!("   Output directory: {output_dir}/"));
334    log::info("");
335    log::info("   Layer             Stems");
336    log::info("   ─────────────────────────────");
337    log::info(&format!("   dialogue          {:3} stems", dialogue.len()));
338    if !vintage_scenes.is_empty() {
339        log::info(&format!(
340            "     vintage scenes : {}  ({vintage_count} stems — mono collapse + LPF 5kHz)",
341            vintage_scenes.join(", ")
342        ));
343    }
344    let filtered = speaker_filter_lines(cast);
345    if !filtered.is_empty() {
346        let parts: Vec<String> = filtered.iter().map(|(k, v)| format!("{k}={v}")).collect();
347        log::info(&format!("     per-speaker    : {}", parts.join("  ")));
348    }
349    log::info(&format!(
350        "   ambience          {:3} stems  (looped to scene boundaries)",
351        count("AMBIENCE")
352    ));
353    log::info(&format!(
354        "   music             {:3} stems  (one-shot at cue points)",
355        count("MUSIC")
356    ));
357    log::info(&format!(
358        "   vintage filter    {:3} stems  (crackle looped to DISENGAGES)",
359        count("VINTAGE FILTER")
360    ));
361    log::info(&format!("   sfx               {sfx:3} stems"));
362    log::info("");
363    log::info("   Output files (all same duration as foreground track):");
364    for (_, suffix, desc) in LAYERS {
365        log::info(&format!("     {output_dir}/{tag}_{suffix}.wav  — {desc}"));
366    }
367    log::info(&format!("     {output_dir}/{tag}_open_in_audacity.py"));
368    log::info("");
369    Ok(())
370}
371
372struct Ctx<'a> {
373    cast: &'a CastConfig,
374    stems_dir: String,
375    parsed_path: PathBuf,
376    output_dir: String,
377    tag: String,
378    slug: String,
379    sfx: Option<&'a SfxConfig>,
380    a: &'a Args,
381}
382
383fn write_timeline_outputs(
384    c: &Ctx,
385    plans: &[StemPlan],
386    index: &IndexMap<i64, Map<String, Value>>,
387    total_ms: i64,
388    timeline: &IndexMap<i64, i64>,
389    layers: [Vec<Label>; 4],
390) -> anyhow::Result<()> {
391    let [amb, mus, sfx, vf] = layers;
392    let dlg = mix::compute_dialogue_labels(plans, timeline)?;
393    let td = build_timeline_data(
394        &c.tag,
395        total_ms as f64 / 1000.0,
396        dlg,
397        amb,
398        mus,
399        sfx,
400        vf,
401        mix::derive_structure_bands(index, timeline, total_ms, "section"),
402        mix::derive_structure_bands(index, timeline, total_ms, "scene"),
403    );
404    let txt = Path::new(&c.output_dir).join(format!("{}_timeline.txt", c.tag));
405    render_text_timeline_map(&td, &txt, &c.slug)?;
406    log::info(&format!("    Written: {}", txt.display()));
407    if c.a.timeline {
408        println!("{}", render_terminal_timeline(&td));
409    }
410    if c.a.timeline_html {
411        let html = Path::new(&c.output_dir).join(format!("{}_timeline.html", c.tag));
412        render_html_timeline(
413            &td,
414            &html,
415            Some(Path::new(&c.stems_dir)),
416            &c.slug,
417            &c.tag,
418            Some(Path::new(&c.output_dir)),
419        )?;
420        log::info(&format!("    Written: {}", html.display()));
421    }
422    Ok(())
423}
424
425fn export_layer(
426    c: &Ctx,
427    layer: xil_audio::segment::Segment,
428    key: &str,
429    title: &str,
430    layer_files: &mut Vec<(String, String)>,
431) -> anyhow::Result<()> {
432    let fname = format!("{}_layer_{key}.wav", c.tag);
433    let wav = Path::new(&c.output_dir).join(&fname);
434    layer.export_wav(&wav)?;
435    drop(layer);
436    tag_wav(
437        &wav,
438        &c.cast.show,
439        Some(&format!("{} {title}", c.tag)),
440        Some(&c.cast.artist),
441    )?;
442    layer_files.push((title.to_string(), fname.clone()));
443    log::info(&format!("    Written: {}/{fname}", c.output_dir));
444    Ok(())
445}
446
447fn export_labels(
448    c: &Ctx,
449    key: &str,
450    title: &str,
451    labels: &[Label],
452    layer_files: &mut Vec<(String, String)>,
453) -> anyhow::Result<()> {
454    let fname = format!("{}_labels_{key}.txt", c.tag);
455    write_labels(&c.output_dir, &fname, labels)?;
456    layer_files.push((format!("Labels ({title})"), fname.clone()));
457    log::info(&format!("    Written: {}/{fname}", c.output_dir));
458    Ok(())
459}
460
461/// `export_daw_layers(...)`.
462fn export_daw_layers(c: &Ctx) -> anyhow::Result<()> {
463    validate_tag_for_script(&c.tag)?;
464    let index = mix::load_entries_index(&c.parsed_path)?;
465    let plans = mix::collect_stem_plans(Path::new(&c.stems_dir), &index, c.sfx);
466    if plans.is_empty() {
467        log::warning(&format!(
468            "No stems found in {}/. Run XILP002 first.",
469            c.stems_dir
470        ));
471        return Ok(());
472    }
473    log::info(&format!(
474        "--- Building foreground timeline from {} stems ---",
475        plans.len()
476    ));
477    let parsed_scenes = derive_vintage_scenes_from_parsed(&c.parsed_path);
478    let config_scenes: Vec<String> = c.sfx.map(|s| s.vintage_scenes.clone()).unwrap_or_default();
479    let vintage_scenes = if parsed_scenes.is_empty() {
480        config_scenes.clone()
481    } else {
482        parsed_scenes.clone()
483    };
484    if !parsed_scenes.is_empty() && parsed_scenes != config_scenes && !config_scenes.is_empty() {
485        log::info(&format!(
486            "    vintage_scenes: using parsed JSON ({} scenes) — sfx_config list ignored",
487            parsed_scenes.len()
488        ));
489    }
490    let cast = &c.cast.cast;
491    let (foreground, cue_timeline) =
492        mix::build_foreground(&plans, cast, c.a.gap_ms, &vintage_scenes)?;
493    if foreground.len_ms() == 0 {
494        log::warning("No foreground stems — cannot determine episode duration.");
495        return Ok(());
496    }
497    let total_ms = foreground.len_ms();
498    drop(foreground);
499    log::info(&format!(
500        "    Episode duration: {:.1}s",
501        total_ms as f64 / 1000.0
502    ));
503    fs::create_dir_all(&c.output_dir)?;
504    let mut layer_files: Vec<(String, String)> = Vec::new();
505
506    log::info("--- Building dialogue layer ---");
507    if !vintage_scenes.is_empty() {
508        let n = plans
509            .iter()
510            .filter(|p| {
511                p.is_dialogue() && p.scene.as_ref().is_some_and(|s| vintage_scenes.contains(s))
512            })
513            .count();
514        log::info(&format!(
515            "    vintage scenes : {}  ({n} stems — mono collapse + LPF 5kHz)",
516            vintage_scenes.join(", ")
517        ));
518    }
519    for (speaker, fval) in speaker_filter_lines(cast) {
520        log::info(&format!("    per-speaker    : {speaker} → {fval}"));
521    }
522    let (dlg, labels) =
523        mix::build_dialogue_layer(&plans, &cue_timeline, total_ms, cast, &vintage_scenes)?;
524    export_layer(c, dlg, "dialogue", "Dialogue", &mut layer_files)?;
525    export_labels(c, "dialogue", "Dialogue", &labels, &mut layer_files)?;
526
527    log::info("--- Building ambience layer ---");
528    for plan in mix::by_seq(&plans) {
529        if plan.dt() != Some("AMBIENCE") || plan.filepath.is_empty() {
530            continue;
531        }
532        log::info(&format!(
533            "    seq {}: vol={}  trim={}  ramp_in={}  ramp_out={}  {}",
534            plan.seq,
535            fmt_vol(plan.volume_percentage),
536            fmt_pct_1(plan.play_duration),
537            fmt_secs(plan.ramp_in_seconds),
538            fmt_secs(plan.ramp_out_seconds),
539            xil_core::fsutil::basename(Path::new(&plan.filepath))
540        ));
541    }
542    let (amb, amb_labels) = mix::build_ambience_layer(&plans, &cue_timeline, total_ms, 0.0)?;
543    export_layer(c, amb, "ambience", "Ambience", &mut layer_files)?;
544    export_labels(c, "ambience", "Ambience", &amb_labels, &mut layer_files)?;
545
546    log::info("--- Building music layer ---");
547    for plan in mix::by_seq(&plans) {
548        if plan.dt() != Some("MUSIC") || plan.filepath.is_empty() {
549            continue;
550        }
551        log::info(&format!(
552            "    seq {}: vol={}  trim={}  ramp_out={}  {}",
553            plan.seq,
554            fmt_vol(plan.volume_percentage),
555            fmt_pct_1(plan.play_duration),
556            fmt_secs(plan.ramp_out_seconds),
557            xil_core::fsutil::basename(Path::new(&plan.filepath))
558        ));
559    }
560    let (mus, mus_labels) = mix::build_music_layer(&plans, &cue_timeline, total_ms, 0.0, true)?;
561    export_layer(c, mus, "music", "Music", &mut layer_files)?;
562    export_labels(c, "music", "Music", &mus_labels, &mut layer_files)?;
563
564    log::info("--- Building SFX layer ---");
565    let (sfx, sfx_labels) = mix::build_sfx_layer(&plans, &cue_timeline, total_ms)?;
566    export_layer(c, sfx, "sfx", "SFX", &mut layer_files)?;
567    export_labels(c, "sfx", "SFX", &sfx_labels, &mut layer_files)?;
568
569    log::info("--- Building vintage filter layer ---");
570    let (vf, vf_labels) = mix::build_vintage_filter_layer(&plans, &cue_timeline, total_ms, 0.0)?;
571    export_layer(c, vf, "vintage_filter", "Vintage Filter", &mut layer_files)?;
572    export_labels(
573        c,
574        "vintage_filter",
575        "Vintage Filter",
576        &vf_labels,
577        &mut layer_files,
578    )?;
579
580    let script_fname = format!("{}_open_in_audacity.py", c.tag);
581    let script_path = Path::new(&c.output_dir).join(&script_fname);
582    fs::write(
583        &script_path,
584        make_audacity_script(&c.tag, &layer_files, c.a.save_aup3, &c.cast.show),
585    )?;
586    #[cfg(unix)]
587    {
588        use std::os::unix::fs::PermissionsExt;
589        fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
590    }
591    log::info(&format!("    Written: {}/{script_fname}", c.output_dir));
592
593    if c.a.macro_ {
594        let path = generate_audacity_macro(
595            &c.output_dir,
596            &c.tag,
597            &layer_files,
598            &c.cast.show,
599            c.cast.season_title.as_deref(),
600            c.cast.title.as_deref(),
601            &c.cast.artist,
602        )?;
603        match path {
604            Some(p) => {
605                log::info(&format!("    Written: {}", p.display()));
606                if has_audacity4() {
607                    log::warning(
608                        "Audacity 4 has no Macro Manager — this macro runs in Audacity 3 only. In Audacity 4, import the layer WAVs manually (see the helper script).",
609                    );
610                }
611            }
612            None => log::warning("Audacity Macros directory not found — macro not written."),
613        }
614    }
615
616    write_timeline_outputs(
617        c,
618        &plans,
619        &index,
620        total_ms,
621        &cue_timeline,
622        [amb_labels, mus_labels, sfx_labels, vf_labels],
623    )?;
624
625    log::info("");
626    log::info(&format!(
627        "--- Done! {} layer WAVs in {}/ ---",
628        layer_files.len(),
629        c.output_dir
630    ));
631    log::info(&format!(
632        "    Import into Audacity: python {}/{script_fname}",
633        c.output_dir
634    ));
635    if c.a.macro_ {
636        log::info(&format!(
637            "    Audacity macro:       Tools → Macros → {}_{} → Apply to Project  (Audacity 3 only)",
638            macro_slug(&c.cast.show),
639            c.tag
640        ));
641    }
642    if c.a.save_aup3 {
643        log::info(&format!(
644            "    Will save project:    {}/{}.aup3",
645            c.output_dir, c.tag
646        ));
647    }
648    Ok(())
649}
650
651pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
652    log::init("daw");
653    let _banner = banner::begin(super::prog(), &super::argv_line(args));
654    let a: Args = match super::parse_or_exit("xil-daw", args) {
655        Ok(a) => a,
656        Err(code) => return Ok(code),
657    };
658    execute(&a)?;
659    Ok(0)
660}
661
662fn execute(a: &Args) -> anyhow::Result<()> {
663    let arg_tag = a
664        .episode
665        .clone()
666        .or_else(|| a.tag.clone())
667        .unwrap_or_default();
668    let slug = resolve_slug(a.show.as_deref(), "project.json");
669    let p = derive_paths(&slug, &arg_tag);
670    let cast_path = &p["cast"];
671    if !cast_path.exists() {
672        log::error(&format!("Cast config not found: {}", cast_path.display()));
673        log::info("Run XILP001 first or check your --episode flag.");
674        return Ok(());
675    }
676    let cast = CastConfig::load(cast_path)?;
677    let tag = cast.tag.clone();
678    let stems_dir = mix::join(&workspace_root().join("stems").join(&slug), &tag);
679    let parsed_path = a
680        .parsed
681        .clone()
682        .filter(|s| !s.is_empty())
683        .map(PathBuf::from)
684        .unwrap_or_else(|| p["parsed"].clone());
685    let output_dir = a
686        .output_dir
687        .clone()
688        .filter(|s| !s.is_empty())
689        .unwrap_or_else(|| p["daw"].to_string_lossy().into_owned());
690
691    if !parsed_path.exists() {
692        log::warning(&format!(
693            "Parsed JSON not found: {}. Run XILP001 first.",
694            xil_audio::fx::py_repr(&parsed_path.to_string_lossy())
695        ));
696        return Ok(());
697    }
698    let sfx = if p["sfx"].exists() {
699        Some(SfxConfig::load(&p["sfx"])?)
700    } else {
701        None
702    };
703
704    let index = mix::load_entries_index(&parsed_path)?;
705    let plans = mix::collect_stem_plans(Path::new(&stems_dir), &index, sfx.as_ref());
706    let derived = derive_vintage_scenes_from_parsed(&parsed_path);
707    let config_scenes: Vec<String> = sfx
708        .as_ref()
709        .map(|s| s.vintage_scenes.clone())
710        .unwrap_or_default();
711    let resolved = if derived.is_empty() {
712        config_scenes.clone()
713    } else {
714        derived.clone()
715    };
716    if !derived.is_empty() && derived != config_scenes && !config_scenes.is_empty() {
717        log::info(&format!(
718            "  vintage_scenes: derived from parsed JSON ({} scenes)",
719            derived.len()
720        ));
721    }
722
723    let c = Ctx {
724        cast: &cast,
725        stems_dir,
726        parsed_path,
727        output_dir,
728        tag,
729        slug,
730        sfx: sfx.as_ref(),
731        a,
732    };
733
734    if a.dry_run {
735        dry_run_daw(
736            &c.tag,
737            &plans,
738            &c.output_dir,
739            &c.stems_dir,
740            &cast.cast,
741            &resolved,
742        )?;
743        let (total_ms, timeline) = mix::build_foreground_timeline_only(&plans, a.gap_ms)?;
744        let amb = mix::compute_ambience_labels(&plans, &timeline, total_ms);
745        let mus = mix::compute_music_labels(&plans, &timeline, total_ms, true)?;
746        let sfx_l = mix::compute_sfx_labels(&plans, &timeline)?;
747        let vf = mix::compute_vintage_filter_labels(&plans, &timeline, total_ms);
748        return write_timeline_outputs(
749            &c,
750            &plans,
751            &index,
752            total_ms,
753            &timeline,
754            [amb, mus, sfx_l, vf],
755        );
756    }
757    export_daw_layers(&c)
758}
759
760/// The clap definition behind `--help`, for man pages.
761pub fn command() -> clap::Command {
762    <Args as clap::CommandFactory>::command()
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    #[test]
770    fn unsafe_tags_are_refused() {
771        assert!(validate_tag_for_script("S01E01").is_ok());
772        assert!(validate_tag_for_script("V01-C_3").is_ok());
773        assert!(validate_tag_for_script("S01E01\"; rm").is_err());
774        assert!(validate_tag_for_script("").is_err());
775    }
776
777    #[test]
778    fn layers_repr_is_python_repr() {
779        let files = vec![(
780            "Dialogue".to_string(),
781            "S01E01_layer_dialogue.wav".to_string(),
782        )];
783        assert_eq!(
784            layers_repr(&files),
785            "[('Dialogue', 'S01E01_layer_dialogue.wav')]"
786        );
787    }
788
789    #[test]
790    fn script_template_is_filled() {
791        let files = vec![("SFX".to_string(), "T_layer_sfx.wav".to_string())];
792        let s = make_audacity_script("T", &files, true, "");
793        assert!(!s.contains("@@XIL_"));
794        assert!(s.contains("\"\"\"Open Episode T DAW layers in Audacity."));
795        assert!(s.contains("T.aup3"));
796    }
797}