Skip to main content

xil/cmd/
setup.rs

1//! `xil setup` — build the venv a local ML worker runs under. Port of
2//! `XILU023_setup.py`.
3//!
4//! * `chatterbox` — `venv-chatterbox` for the Chatterbox Turbo TTS worker:
5//!   PyTorch, `chatterbox-tts` and `pydub`.
6//! * `whisper` — `venv-whisper` for `xil stem-verify`: `faster-whisper`
7//!   (CTranslate2, no PyTorch).
8//! * `mmaudio` — `venv-mmaudio` for `--sfx-backend mmaudio`: a pinned clone
9//!   of hkchengrex/MMAudio installed editable, then PyTorch re-pinned
10//!   afterwards (MMAudio's unbounded `torch` pulls a CUDA 13 build).
11
12use std::env;
13use std::ffi::OsString;
14use std::fs;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::{Command, Stdio};
18
19use clap::{Parser, ValueEnum};
20use xil_core::log;
21use xil_core::workspace::{code_root, workspace_root};
22
23/// One `xil setup` target. `repo` (URL, ref) is cloned next to the venv as
24/// `MMAudio` and installed editable before `packages`; `torch` is installed
25/// last so it wins over whatever `packages` pulled in.
26struct Spec {
27    venv: &'static str,
28    python: &'static str,
29    torch: &'static [&'static str],
30    packages: &'static [&'static str],
31    label: &'static str,
32    import_name: &'static str,
33    verify: &'static str,
34    repo: Option<(&'static str, &'static str)>,
35}
36
37const CHATTERBOX: Spec = Spec {
38    venv: "venv-chatterbox",
39    python: "3.13",
40    torch: &["torch==2.6.0", "torchaudio==2.6.0"],
41    packages: &["chatterbox-tts", "pydub"],
42    label: "install chatterbox-tts",
43    import_name: "chatterbox",
44    verify: "import torch, torchaudio, pydub; \
45             from chatterbox.tts_turbo import ChatterboxTurboTTS; \
46             print(torch.cuda.is_available())",
47    repo: None,
48};
49
50const WHISPER: Spec = Spec {
51    venv: "venv-whisper",
52    python: "3.13",
53    torch: &[],
54    packages: &["faster-whisper"],
55    label: "install faster-whisper",
56    import_name: "faster_whisper",
57    verify: "import ctranslate2; from faster_whisper import WhisperModel; \
58             print(ctranslate2.get_cuda_device_count() > 0)",
59    repo: None,
60};
61
62/// MMAudio pins numpy<2.1, which has no Python 3.13 wheels: default to 3.12.
63const MMAUDIO: Spec = Spec {
64    venv: "venv-mmaudio",
65    python: "3.12",
66    torch: &["torch==2.6.0", "torchaudio==2.6.0", "torchvision==0.21.0"],
67    packages: &["pydub"],
68    label: "install MMAudio",
69    import_name: "mmaudio",
70    verify: "import torch, torchaudio, pydub; \
71             from mmaudio.eval_utils import all_model_cfg; \
72             print(torch.cuda.is_available())",
73    repo: Some(("https://github.com/hkchengrex/MMAudio", "974010a")),
74};
75
76/// The checkpoint of the worker's model (`mmaudio_worker._DEFAULT_VARIANT`).
77const MMAUDIO_WEIGHTS: [&str; 2] = ["weights", "mmaudio_large_44k_v2.pth"];
78
79#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
80enum Target {
81    /// Chatterbox Turbo local TTS (venv-chatterbox)
82    Chatterbox,
83    /// faster-whisper for xil stem-verify (venv-whisper)
84    Whisper,
85    /// MMAudio local SFX (venv-mmaudio)
86    Mmaudio,
87}
88
89impl Target {
90    fn spec(self) -> &'static Spec {
91        match self {
92            Target::Chatterbox => &CHATTERBOX,
93            Target::Whisper => &WHISPER,
94            Target::Mmaudio => &MMAUDIO,
95        }
96    }
97}
98
99#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
100enum Device {
101    Auto,
102    Cuda,
103    Cpu,
104}
105
106#[derive(Parser)]
107#[command(
108    name = "xil-setup",
109    about = "Create and verify the virtual environment a local ML worker runs \
110             under. 'chatterbox' builds venv-chatterbox (PyTorch + \
111             chatterbox-tts) for local TTS; 'whisper' builds venv-whisper \
112             (faster-whisper) for xil stem-verify; 'mmaudio' clones MMAudio \
113             and builds venv-mmaudio for local SFX. PyTorch comes as CUDA \
114             wheels when an NVIDIA GPU is found, CPU wheels otherwise.",
115    after_help = "The venv is created in --dir, else $XIL_CODEROOT, else the \
116                  workspace root: the places the xil commands look for it. Uses \
117                  uv when it is on PATH, otherwise python -m venv and pip. \
118                  Running it again on a working venv does nothing."
119)]
120struct Args {
121    /// what to set up
122    #[arg(value_enum)]
123    target: Target,
124    /// PyTorch build to install (auto: CUDA if nvidia-smi works, else CPU)
125    #[arg(long, value_enum, default_value = "auto")]
126    device: Device,
127    /// PyTorch CUDA wheel index tag
128    #[arg(long, default_value = "cu124", value_name = "TAG")]
129    cuda_index: String,
130    /// directory to create the venv in
131    #[arg(long, value_name = "PATH")]
132    dir: Option<PathBuf>,
133    /// Python version for the venv (default: 3.13; 3.12 for mmaudio)
134    #[arg(long, value_name = "VER")]
135    python: Option<String>,
136    /// delete and rebuild an existing venv
137    #[arg(long)]
138    force: bool,
139    /// print the commands without running them
140    #[arg(long)]
141    dry_run: bool,
142}
143
144/// What builds the venv.
145#[derive(Debug, Clone)]
146enum Installer {
147    Uv(PathBuf),
148    /// A base interpreter for `-m venv`; pip inside the venv does the rest.
149    Pip(PathBuf),
150}
151
152#[derive(Debug)]
153struct Step {
154    label: String,
155    program: PathBuf,
156    args: Vec<String>,
157}
158
159impl Step {
160    fn new(label: impl Into<String>, program: &Path, args: &[&str]) -> Self {
161        Step {
162            label: label.into(),
163            program: program.to_path_buf(),
164            args: args.iter().map(|s| s.to_string()).collect(),
165        }
166    }
167
168    fn display(&self) -> String {
169        let mut s = self.program.display().to_string();
170        for a in &self.args {
171            s.push(' ');
172            s.push_str(a);
173        }
174        s
175    }
176}
177
178fn venv_python(venv: &Path) -> PathBuf {
179    venv.join("bin").join("python3")
180}
181
182/// The directory that holds the venv (Python's `os.path.dirname`).
183fn venv_parent(venv: &Path) -> &Path {
184    venv.parent().unwrap_or(Path::new(""))
185}
186
187/// The MMAudio clone sits beside its venv.
188fn repo_dir(venv: &Path) -> PathBuf {
189    venv_parent(venv).join("MMAudio")
190}
191
192fn find_on_path(name: &str) -> Option<PathBuf> {
193    let path = env::var_os("PATH")?;
194    env::split_paths(&path)
195        .map(|d| d.join(name))
196        .find(|p| p.is_file())
197}
198
199fn find_installer(python_ver: &str) -> Option<Installer> {
200    if let Some(uv) = find_on_path("uv") {
201        return Some(Installer::Uv(uv));
202    }
203    find_on_path(&format!("python{python_ver}"))
204        .or_else(|| find_on_path("python3"))
205        .map(Installer::Pip)
206}
207
208fn detect_cuda() -> bool {
209    Command::new("nvidia-smi")
210        .stdout(Stdio::null())
211        .stderr(Stdio::null())
212        .status()
213        .is_ok_and(|s| s.success())
214}
215
216fn use_cuda(device: Device, detected: bool) -> bool {
217    match device {
218        Device::Auto => detected,
219        Device::Cuda => true,
220        Device::Cpu => false,
221    }
222}
223
224/// Where the venv goes: `--dir`, else `$XIL_CODEROOT`, else the workspace.
225/// The same places `resolve_venv_python` searches.
226fn target_dir(
227    explicit: Option<PathBuf>,
228    code_root: Option<PathBuf>,
229    workspace: PathBuf,
230) -> PathBuf {
231    explicit.or(code_root).unwrap_or(workspace)
232}
233
234fn index_url(cuda: bool, cuda_index: &str) -> String {
235    let tag = if cuda { cuda_index } else { "cpu" };
236    format!("https://download.pytorch.org/whl/{tag}")
237}
238
239fn needs_clone(spec: &Spec, venv: &Path) -> bool {
240    spec.repo.is_some() && !repo_dir(venv).is_dir()
241}
242
243fn plan(
244    installer: &Installer,
245    venv: &Path,
246    python_ver: &str,
247    cuda: bool,
248    cuda_index: &str,
249    spec: &Spec,
250    git: &Path,
251) -> Vec<Step> {
252    let venv_s = venv.to_string_lossy().into_owned();
253    let py = venv_python(venv);
254    let py_s = py.to_string_lossy().into_owned();
255    let index = index_url(cuda, cuda_index);
256
257    let (mut steps, install_prog, install_args): (Vec<Step>, PathBuf, Vec<&str>) = match installer {
258        Installer::Uv(uv) => (
259            vec![Step::new(
260                "create venv",
261                uv,
262                &["venv", &venv_s, "--python", python_ver],
263            )],
264            uv.clone(),
265            vec!["pip", "install", "--python", &py_s],
266        ),
267        Installer::Pip(base) => (
268            vec![
269                Step::new("create venv", base, &["-m", "venv", &venv_s]),
270                Step::new(
271                    "upgrade pip",
272                    &py,
273                    &["-m", "pip", "install", "--upgrade", "pip"],
274                ),
275            ],
276            py.clone(),
277            vec!["-m", "pip", "install"],
278        ),
279    };
280    let install_step = |label: &str, args: &[&str]| {
281        let mut all = install_args.clone();
282        all.extend_from_slice(args);
283        Step::new(label, &install_prog, &all)
284    };
285
286    let mut torch_args: Vec<&str> = spec.torch.to_vec();
287    torch_args.extend(["--index-url", &index]);
288    let torch = install_step("install PyTorch", &torch_args);
289
290    let Some((url, git_ref)) = spec.repo else {
291        if !spec.torch.is_empty() {
292            steps.push(torch);
293        }
294        steps.push(install_step(spec.label, spec.packages));
295        return steps;
296    };
297
298    let repo = repo_dir(venv);
299    let repo_s = repo.to_string_lossy().into_owned();
300    if needs_clone(spec, venv) {
301        steps.push(Step::new("clone MMAudio", git, &["clone", url, &repo_s]));
302        steps.push(Step::new(
303            format!("check out MMAudio {git_ref}"),
304            git,
305            &["-C", &repo_s, "checkout", git_ref],
306        ));
307    }
308    let mut pkg_args = vec!["-e", repo_s.as_str()];
309    pkg_args.extend_from_slice(spec.packages);
310    steps.push(install_step(spec.label, &pkg_args));
311    steps.push(torch);
312    steps
313}
314
315/// `Some(gpu_available)` when the venv imports everything the worker needs.
316fn verify(python: &Path, spec: &Spec) -> Option<bool> {
317    if !python.exists() {
318        return None;
319    }
320    let out = Command::new(python)
321        .args(["-c", spec.verify])
322        .output()
323        .ok()?;
324    if !out.status.success() {
325        return None;
326    }
327    match String::from_utf8_lossy(&out.stdout).lines().last()?.trim() {
328        "True" => Some(true),
329        "False" => Some(false),
330        _ => None,
331    }
332}
333
334struct Opts {
335    venv: PathBuf,
336    python_ver: String,
337    cuda: bool,
338    cuda_index: String,
339    force: bool,
340    dry_run: bool,
341    coderoot_set: bool,
342    target: Target,
343    git: Option<PathBuf>,
344}
345
346fn header(opts: &Opts) -> String {
347    if opts.target.spec().torch.is_empty() {
348        return format!("Setting up {}.", opts.venv.display());
349    }
350    format!(
351        "Setting up {} with {} PyTorch wheels.",
352        opts.venv.display(),
353        device_name(opts.cuda)
354    )
355}
356
357fn no_gpu_warning(target: Target) -> String {
358    let worker = match target {
359        Target::Whisper => {
360            return "nvidia-smi works but CTranslate2 sees no GPU; Whisper will run on \
361                    the CPU (int8). Check your NVIDIA driver."
362                .to_string()
363        }
364        Target::Mmaudio => "MMAudio",
365        Target::Chatterbox => "Chatterbox Turbo",
366    };
367    format!(
368        "CUDA wheels were installed but torch sees no GPU; {worker} will fall back \
369         to the CPU. Check your NVIDIA driver, or try another --cuda-index."
370    )
371}
372
373fn next_steps(opts: &Opts) -> Vec<String> {
374    match opts.target {
375        Target::Whisper => vec!["  xil stem-verify --episode S01E01".into()],
376        Target::Mmaudio => {
377            let weights = MMAUDIO_WEIGHTS
378                .iter()
379                .fold(venv_parent(&opts.venv).to_path_buf(), |p, s| p.join(s));
380            let found = if weights.is_file() {
381                format!("  Weights found: {}", weights.display())
382            } else {
383                format!(
384                    "  Weights not found: {} (about 6 GB downloads on the first run).",
385                    weights.display()
386                )
387            };
388            vec![
389                found,
390                "  MMAudio weights are CC BY-NC 4.0: non-commercial use only.".into(),
391                "  xil sfx --episode S01E01 --gen-sfx --sfx-backend mmaudio \
392                 --mmaudio-accept-noncommercial"
393                    .into(),
394            ]
395        }
396        Target::Chatterbox => vec![
397            "  Save one clip per speaker as voice_refs/<speaker_key>.wav (over 5 seconds).".into(),
398            "  If the model is gated for your account: export HF_TOKEN=hf_... \
399             (weights download on first render)."
400                .into(),
401            "  xil produce --episode S01E01 --backend chatterbox-turbo".into(),
402        ],
403    }
404}
405
406fn execute(opts: &Opts, installer: Option<Installer>, out: &mut impl Write) -> anyhow::Result<i32> {
407    let spec = opts.target.spec();
408    let python = venv_python(&opts.venv);
409
410    if !opts.force && !opts.dry_run {
411        if let Some(cuda) = verify(&python, spec) {
412            writeln!(
413                out,
414                "{} is already set up ({}).",
415                opts.venv.display(),
416                device_name(cuda)
417            )?;
418            writeln!(out, "Use --force to rebuild it.")?;
419            return Ok(0);
420        }
421    }
422
423    let Some(installer) = installer else {
424        log::error(&format!(
425            "Neither uv nor python{} / python3 was found on PATH. Install uv \
426             (https://docs.astral.sh/uv/) or Python 3.10-3.13, then retry.",
427            opts.python_ver
428        ));
429        return Ok(1);
430    };
431
432    if let Installer::Pip(base) = &installer {
433        let wanted = format!("python{}", opts.python_ver);
434        if base.file_name().and_then(|n| n.to_str()) != Some(wanted.as_str()) {
435            log::warning(&format!(
436                "python{} was not found on PATH; building with {} instead. Install uv \
437                 (it fetches the right Python) if the build fails.",
438                opts.python_ver,
439                base.display()
440            ));
441        }
442    }
443
444    if needs_clone(spec, &opts.venv) && opts.git.is_none() {
445        let (url, _) = spec.repo.unwrap_or_default();
446        log::error(&format!(
447            "git was not found on PATH; it is needed to clone {url} into {}. \
448             Install git, or clone it there yourself, then retry.",
449            repo_dir(&opts.venv).display()
450        ));
451        return Ok(1);
452    }
453
454    let git = opts.git.clone().unwrap_or_else(|| PathBuf::from("git"));
455    let steps = plan(
456        &installer,
457        &opts.venv,
458        &opts.python_ver,
459        opts.cuda,
460        &opts.cuda_index,
461        spec,
462        &git,
463    );
464    writeln!(out, "{}", header(opts))?;
465
466    if opts.dry_run {
467        if opts.force && opts.venv.exists() {
468            writeln!(out, "rm -rf {}", opts.venv.display())?;
469        }
470        for s in &steps {
471            writeln!(out, "{}", s.display())?;
472        }
473        return Ok(0);
474    }
475
476    if opts.venv.exists() {
477        if opts.force {
478            writeln!(out, "Removing {}", opts.venv.display())?;
479            fs::remove_dir_all(&opts.venv)?;
480        } else {
481            log::error(&format!(
482                "{} exists but does not import {}. Rerun with --force to rebuild it.",
483                opts.venv.display(),
484                spec.import_name
485            ));
486            return Ok(1);
487        }
488    }
489
490    let total = steps.len();
491    for (i, s) in steps.iter().enumerate() {
492        writeln!(out, "[{}/{}] {}: {}", i + 1, total, s.label, s.display())?;
493        out.flush()?;
494        let status = Command::new(&s.program).args(&s.args).status()?;
495        if !status.success() {
496            log::error(&format!("Step '{}' failed ({status}).", s.label));
497            return Ok(1);
498        }
499    }
500
501    let Some(cuda) = verify(&python, spec) else {
502        log::error(&format!(
503            "{} was built but does not import {}. Check the output above.",
504            opts.venv.display(),
505            spec.import_name
506        ));
507        return Ok(1);
508    };
509    writeln!(
510        out,
511        "Verified: {} imports; device {}.",
512        spec.import_name,
513        device_name(cuda)
514    )?;
515    if opts.cuda && !cuda {
516        log::warning(&no_gpu_warning(opts.target));
517    }
518
519    writeln!(out, "\nNext:")?;
520    if !opts.coderoot_set {
521        writeln!(
522            out,
523            "  export XIL_CODEROOT={}",
524            venv_parent(&opts.venv).display()
525        )?;
526    }
527    for line in next_steps(opts) {
528        writeln!(out, "{line}")?;
529    }
530    Ok(0)
531}
532
533fn device_name(cuda: bool) -> &'static str {
534    if cuda {
535        "CUDA"
536    } else {
537        "CPU"
538    }
539}
540
541pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
542    log::init("setup");
543    let parsed: Args = match super::parse_or_exit("xil-setup", args) {
544        Ok(a) => a,
545        Err(code) => return Ok(code),
546    };
547    let spec = parsed.target.spec();
548    let python_ver = parsed.python.unwrap_or_else(|| spec.python.to_string());
549
550    let detected = parsed.device == Device::Auto && detect_cuda();
551    let code_root = code_root();
552    let opts = Opts {
553        venv: target_dir(parsed.dir, code_root.clone(), workspace_root()).join(spec.venv),
554        python_ver,
555        cuda: use_cuda(parsed.device, detected),
556        cuda_index: parsed.cuda_index,
557        force: parsed.force,
558        dry_run: parsed.dry_run,
559        coderoot_set: code_root.is_some(),
560        target: parsed.target,
561        git: find_on_path("git"),
562    };
563    let installer = find_installer(&opts.python_ver);
564    execute(&opts, installer, &mut std::io::stdout().lock())
565}
566
567/// The clap definition behind `--help`, for man pages.
568pub fn command() -> clap::Command {
569    <Args as clap::CommandFactory>::command()
570}
571
572#[cfg(all(test, unix))]
573mod tests {
574    use super::*;
575    use std::os::unix::fs::PermissionsExt;
576
577    /// Writing a script while another test forks can leave it "Text file
578    /// busy" at exec; these tests write and run scripts, so they take turns.
579    static SCRIPTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
580
581    fn serial() -> std::sync::MutexGuard<'static, ()> {
582        SCRIPTS.lock().unwrap_or_else(|e| e.into_inner())
583    }
584
585    fn script(path: &Path, body: &str) {
586        fs::create_dir_all(path.parent().unwrap()).unwrap();
587        fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap();
588        fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
589    }
590
591    fn opts(venv: PathBuf) -> Opts {
592        Opts {
593            venv,
594            python_ver: "3.13".into(),
595            cuda: false,
596            cuda_index: "cu124".into(),
597            force: false,
598            dry_run: false,
599            coderoot_set: true,
600            target: Target::Chatterbox,
601            git: Some(PathBuf::from("/usr/bin/git")),
602        }
603    }
604
605    fn displays(steps: &[Step]) -> Vec<String> {
606        steps.iter().map(Step::display).collect()
607    }
608
609    const GIT: &str = "/usr/bin/git";
610
611    #[test]
612    fn device_choice() {
613        assert!(use_cuda(Device::Auto, true));
614        assert!(!use_cuda(Device::Auto, false));
615        assert!(use_cuda(Device::Cuda, false));
616        assert!(!use_cuda(Device::Cpu, true));
617    }
618
619    #[test]
620    fn dir_precedence() {
621        let ws = PathBuf::from("/ws");
622        let cr = Some(PathBuf::from("/code"));
623        let ex = Some(PathBuf::from("/x"));
624        assert_eq!(target_dir(ex, cr.clone(), ws.clone()), PathBuf::from("/x"));
625        assert_eq!(target_dir(None, cr, ws.clone()), PathBuf::from("/code"));
626        assert_eq!(target_dir(None, None, ws), PathBuf::from("/ws"));
627    }
628
629    #[test]
630    fn default_python_per_target() {
631        assert_eq!(Target::Chatterbox.spec().python, "3.13");
632        assert_eq!(Target::Whisper.spec().python, "3.13");
633        assert_eq!(Target::Mmaudio.spec().python, "3.12");
634    }
635
636    #[test]
637    fn uv_plan_uses_cuda_or_cpu_index() {
638        let uv = Installer::Uv(PathBuf::from("/bin/uv"));
639        let venv = Path::new("/c/venv-chatterbox");
640        let cuda = plan(
641            &uv,
642            venv,
643            "3.13",
644            true,
645            "cu124",
646            &CHATTERBOX,
647            Path::new(GIT),
648        );
649        assert_eq!(cuda.len(), 3);
650        assert_eq!(
651            cuda[0].display(),
652            "/bin/uv venv /c/venv-chatterbox --python 3.13"
653        );
654        assert_eq!(
655            cuda[1].display(),
656            "/bin/uv pip install --python /c/venv-chatterbox/bin/python3 torch==2.6.0 \
657             torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu124"
658        );
659        assert_eq!(
660            cuda[2].display(),
661            "/bin/uv pip install --python /c/venv-chatterbox/bin/python3 chatterbox-tts pydub"
662        );
663        let cpu = plan(
664            &uv,
665            venv,
666            "3.13",
667            false,
668            "cu124",
669            &CHATTERBOX,
670            Path::new(GIT),
671        );
672        assert!(cpu[1].display().ends_with("/whl/cpu"));
673    }
674
675    #[test]
676    fn pip_plan_runs_inside_the_venv() {
677        let pip = Installer::Pip(PathBuf::from("/usr/bin/python3"));
678        let steps = plan(
679            &pip,
680            Path::new("/c/venv-chatterbox"),
681            "3.13",
682            false,
683            "cu124",
684            &CHATTERBOX,
685            Path::new(GIT),
686        );
687        assert_eq!(steps.len(), 4);
688        assert_eq!(
689            steps[0].display(),
690            "/usr/bin/python3 -m venv /c/venv-chatterbox"
691        );
692        assert!(steps[1..]
693            .iter()
694            .all(|s| s.program == Path::new("/c/venv-chatterbox/bin/python3")));
695        assert!(steps[2]
696            .display()
697            .ends_with("--index-url https://download.pytorch.org/whl/cpu"));
698    }
699
700    #[test]
701    fn whisper_plan_has_no_torch() {
702        let uv = Installer::Uv(PathBuf::from("/bin/uv"));
703        let steps = plan(
704            &uv,
705            Path::new("/c/venv-whisper"),
706            "3.13",
707            true,
708            "cu124",
709            &WHISPER,
710            Path::new(GIT),
711        );
712        assert_eq!(
713            displays(&steps),
714            [
715                "/bin/uv venv /c/venv-whisper --python 3.13",
716                "/bin/uv pip install --python /c/venv-whisper/bin/python3 faster-whisper",
717            ]
718        );
719        let pip = Installer::Pip(PathBuf::from("/usr/bin/python3.13"));
720        let steps = plan(
721            &pip,
722            Path::new("/c/venv-whisper"),
723            "3.13",
724            false,
725            "cu124",
726            &WHISPER,
727            Path::new(GIT),
728        );
729        assert_eq!(steps.len(), 3);
730        assert_eq!(steps[1].label, "upgrade pip");
731        assert_eq!(
732            steps[2].display(),
733            "/c/venv-whisper/bin/python3 -m pip install faster-whisper"
734        );
735    }
736
737    #[test]
738    fn mmaudio_plan_clones_then_repins_torch_last() {
739        let tmp = tempfile::tempdir().unwrap();
740        let venv = tmp.path().join("venv-mmaudio");
741        let repo = tmp.path().join("MMAudio");
742        let uv = Installer::Uv(PathBuf::from("/bin/uv"));
743        let steps = plan(&uv, &venv, "3.12", true, "cu124", &MMAUDIO, Path::new(GIT));
744        let py = venv_python(&venv);
745        assert_eq!(
746            displays(&steps),
747            [
748                format!("/bin/uv venv {} --python 3.12", venv.display()),
749                format!(
750                    "{GIT} clone https://github.com/hkchengrex/MMAudio {}",
751                    repo.display()
752                ),
753                format!("{GIT} -C {} checkout 974010a", repo.display()),
754                format!(
755                    "/bin/uv pip install --python {} -e {} pydub",
756                    py.display(),
757                    repo.display()
758                ),
759                format!(
760                    "/bin/uv pip install --python {} torch==2.6.0 torchaudio==2.6.0 \
761                     torchvision==0.21.0 --index-url https://download.pytorch.org/whl/cu124",
762                    py.display()
763                ),
764            ]
765        );
766        assert_eq!(steps[2].label, "check out MMAudio 974010a");
767
768        // An existing clone is reused.
769        fs::create_dir(&repo).unwrap();
770        let steps = plan(&uv, &venv, "3.12", false, "cu124", &MMAUDIO, Path::new(GIT));
771        assert_eq!(steps.len(), 3);
772        assert!(steps.iter().all(|s| s.program != Path::new(GIT)));
773        assert!(steps[2].display().ends_with("/whl/cpu"));
774    }
775
776    #[test]
777    fn mmaudio_without_git_fails_before_building() {
778        let tmp = tempfile::tempdir().unwrap();
779        let mut o = opts(tmp.path().join("venv-mmaudio"));
780        o.target = Target::Mmaudio;
781        o.git = None;
782        o.dry_run = true;
783        let mut out = Vec::new();
784        let code = execute(&o, Some(Installer::Uv(PathBuf::from("/bin/uv"))), &mut out).unwrap();
785        assert_eq!(code, 1);
786        assert!(out.is_empty());
787
788        // With a clone in place, git is not needed.
789        fs::create_dir(tmp.path().join("MMAudio")).unwrap();
790        let code = execute(&o, Some(Installer::Uv(PathBuf::from("/bin/uv"))), &mut out).unwrap();
791        assert_eq!(code, 0);
792    }
793
794    #[test]
795    fn headers_and_next_steps() {
796        let tmp = tempfile::tempdir().unwrap();
797        let mut o = opts(tmp.path().join("venv-whisper"));
798        o.target = Target::Whisper;
799        assert_eq!(header(&o), format!("Setting up {}.", o.venv.display()));
800        o.target = Target::Mmaudio;
801        o.venv = tmp.path().join("venv-mmaudio");
802        assert!(header(&o).ends_with("with CPU PyTorch wheels."));
803        assert!(next_steps(&o)[0].starts_with("  Weights not found: "));
804        let w = tmp.path().join("weights");
805        fs::create_dir(&w).unwrap();
806        fs::write(w.join("mmaudio_large_44k_v2.pth"), "").unwrap();
807        assert_eq!(
808            next_steps(&o)[0],
809            format!(
810                "  Weights found: {}",
811                w.join("mmaudio_large_44k_v2.pth").display()
812            )
813        );
814    }
815
816    #[test]
817    fn dry_run_runs_nothing() {
818        let _serial = serial();
819        let tmp = tempfile::tempdir().unwrap();
820        let log = tmp.path().join("calls");
821        let uv = tmp.path().join("uv");
822        script(&uv, &format!("echo \"$@\" >> {}", log.display()));
823        let mut o = opts(tmp.path().join(CHATTERBOX.venv));
824        o.dry_run = true;
825        let mut out = Vec::new();
826        let code = execute(&o, Some(Installer::Uv(uv)), &mut out).unwrap();
827        assert_eq!(code, 0);
828        assert!(!log.exists());
829        let text = String::from_utf8(out).unwrap();
830        assert!(text.contains("pip install"));
831        assert!(!tmp.path().join(CHATTERBOX.venv).exists());
832    }
833
834    #[test]
835    fn working_venv_is_left_alone() {
836        let _serial = serial();
837        let tmp = tempfile::tempdir().unwrap();
838        let venv = tmp.path().join(CHATTERBOX.venv);
839        script(&venv_python(&venv), "echo True");
840        let mut out = Vec::new();
841        let code = execute(&opts(venv), None, &mut out).unwrap();
842        assert_eq!(code, 0);
843        assert!(String::from_utf8(out)
844            .unwrap()
845            .contains("already set up (CUDA)"));
846    }
847
848    #[test]
849    fn builds_with_fake_uv_then_verifies() {
850        let _serial = serial();
851        let tmp = tempfile::tempdir().unwrap();
852        let venv = tmp.path().join(CHATTERBOX.venv);
853        let log = tmp.path().join("calls");
854        // `uv venv <dir>` drops a python3 that passes the import check.
855        let uv = tmp.path().join("uv");
856        script(
857            &uv,
858            &format!(
859                "echo \"$@\" >> {log}\nif [ \"$1\" = venv ]; then mkdir -p \"$2/bin\"; \
860                 printf '#!/bin/sh\\necho False\\n' > \"$2/bin/python3\"; chmod +x \"$2/bin/python3\"; fi",
861                log = log.display()
862            ),
863        );
864        let mut out = Vec::new();
865        let code = execute(&opts(venv.clone()), Some(Installer::Uv(uv)), &mut out).unwrap();
866        assert_eq!(code, 0, "{}", String::from_utf8_lossy(&out));
867        let calls = fs::read_to_string(&log).unwrap();
868        assert_eq!(calls.lines().count(), 3);
869        assert!(calls.contains("chatterbox-tts pydub"));
870        assert!(String::from_utf8(out).unwrap().contains("Verified"));
871    }
872
873    #[test]
874    fn broken_venv_needs_force() {
875        let _serial = serial();
876        let tmp = tempfile::tempdir().unwrap();
877        let venv = tmp.path().join(CHATTERBOX.venv);
878        script(&venv_python(&venv), "exit 1");
879        let mut out = Vec::new();
880        let code = execute(
881            &opts(venv),
882            Some(Installer::Uv(PathBuf::from("/nonexistent/uv"))),
883            &mut out,
884        )
885        .unwrap();
886        assert_eq!(code, 1);
887    }
888}