1use 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
23struct 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
62const 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
76const MMAUDIO_WEIGHTS: [&str; 2] = ["weights", "mmaudio_large_44k_v2.pth"];
78
79#[derive(Clone, Copy, PartialEq, Eq, Debug, ValueEnum)]
80enum Target {
81 Chatterbox,
83 Whisper,
85 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 #[arg(value_enum)]
123 target: Target,
124 #[arg(long, value_enum, default_value = "auto")]
126 device: Device,
127 #[arg(long, default_value = "cu124", value_name = "TAG")]
129 cuda_index: String,
130 #[arg(long, value_name = "PATH")]
132 dir: Option<PathBuf>,
133 #[arg(long, value_name = "VER")]
135 python: Option<String>,
136 #[arg(long)]
138 force: bool,
139 #[arg(long)]
141 dry_run: bool,
142}
143
144#[derive(Debug, Clone)]
146enum Installer {
147 Uv(PathBuf),
148 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
182fn venv_parent(venv: &Path) -> &Path {
184 venv.parent().unwrap_or(Path::new(""))
185}
186
187fn 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
224fn 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
315fn 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
567pub 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 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 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 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 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}