#!/usr/bin/env python3 """ VoxCPM Command Line Interface VoxCPM2-first CLI for voice design, cloning, and batch processing. """ import argparse import json import os import sys from pathlib import Path DEFAULT_HF_MODEL_ID = "openbmb/VoxCPM2" # ----------------------------- # Validators # ----------------------------- def validate_file_exists(file_path: str, file_type: str = "file") -> Path: path = Path(file_path) if not path.exists(): raise FileNotFoundError(f"{file_type} '{file_path}' does not exist") return path def require_file_exists(file_path: str, parser, file_type: str = "file") -> Path: try: return validate_file_exists(file_path, file_type) except FileNotFoundError as exc: parser.error(str(exc)) def validate_output_path(output_path: str) -> Path: path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) return path def validate_ranges(args, parser): """Validate numeric argument ranges.""" if not (0.1 <= args.cfg_value <= 10.0): parser.error("--cfg-value must be between 0.1 and 10.0 (recommended: 1.0–3.0)") if not (1 <= args.inference_timesteps <= 100): parser.error("--inference-timesteps must be between 1 and 100 (recommended: 4–30)") if args.lora_r <= 0: parser.error("--lora-r must be a positive integer") if args.lora_alpha <= 0: parser.error("--lora-alpha must be a positive integer") if not (0.0 <= args.lora_dropout <= 1.0): parser.error("--lora-dropout must be between 0.0 and 1.0") def warn_legacy_mode(): print( "Warning: legacy root CLI arguments are deprecated. Prefer `voxcpm design|clone|batch ...`.", file=sys.stderr, ) def build_final_text(text: str, control: str | None) -> str: control = (control or "").strip() return f"({control}){text}" if control else text def resolve_prompt_text(args, parser) -> str | None: prompt_text = getattr(args, "prompt_text", None) prompt_file = getattr(args, "prompt_file", None) if prompt_text and prompt_file: parser.error("Use either --prompt-text or --prompt-file, not both.") if prompt_file: prompt_path = require_file_exists(prompt_file, parser, "prompt text file") return prompt_path.read_text(encoding="utf-8").strip() if prompt_text: return prompt_text.strip() return None def detect_model_architecture(args) -> str | None: model_location = getattr(args, "model_path", None) or getattr( args, "hf_model_id", None ) if not model_location: return None if os.path.isdir(model_location): config_path = Path(model_location) / "config.json" if not config_path.exists(): return None with open(config_path, "r", encoding="utf-8") as f: return json.load(f).get("architecture", "voxcpm").lower() model_hint = str(model_location).lower() if "voxcpm2" in model_hint: return "voxcpm2" if ( "voxcpm1.5" in model_hint or "voxcpm-1.5" in model_hint or "voxcpm_1.5" in model_hint ): return "voxcpm" return None def validate_prompt_related_args(args, parser, prompt_text: str | None): if prompt_text and not args.prompt_audio: parser.error("--prompt-text/--prompt-file requires --prompt-audio.") if args.prompt_audio and not prompt_text: parser.error("--prompt-audio requires --prompt-text or --prompt-file.") if args.control and prompt_text: parser.error( "--control cannot be used together with --prompt-text or --prompt-file." ) def validate_reference_support(args, parser): if not getattr(args, "reference_audio", None): return arch = detect_model_architecture(args) if arch == "voxcpm": parser.error("--reference-audio is only supported with VoxCPM2 models.") def validate_design_args(args, parser): prompt_text = resolve_prompt_text(args, parser) if args.prompt_audio or args.reference_audio or prompt_text: parser.error( "`design` does not accept prompt/reference audio. Use `clone` instead." ) def validate_clone_args(args, parser): prompt_text = resolve_prompt_text(args, parser) validate_prompt_related_args(args, parser, prompt_text) validate_reference_support(args, parser) if not args.prompt_audio and not args.reference_audio: parser.error( "`clone` requires --reference-audio, or --prompt-audio with --prompt-text/--prompt-file." ) return prompt_text def validate_batch_args(args, parser): prompt_text = resolve_prompt_text(args, parser) validate_prompt_related_args(args, parser, prompt_text) validate_reference_support(args, parser) return prompt_text # ----------------------------- # Model loading # ----------------------------- def load_model(args): from voxcpm.core import VoxCPM print("Loading VoxCPM model...", file=sys.stderr) zipenhancer_path = getattr(args, "zipenhancer_path", None) or os.environ.get( "ZIPENHANCER_MODEL_PATH", None ) # Build LoRA config if provided lora_config = None lora_weights_path = getattr(args, "lora_path", None) if lora_weights_path: from voxcpm.model.voxcpm import LoRAConfig lora_config = LoRAConfig( enable_lm=not args.lora_disable_lm, enable_dit=not args.lora_disable_dit, enable_proj=args.lora_enable_proj, r=args.lora_r, alpha=args.lora_alpha, dropout=args.lora_dropout, ) print( f"LoRA config: r={lora_config.r}, alpha={lora_config.alpha}, " f"lm={lora_config.enable_lm}, dit={lora_config.enable_dit}, proj={lora_config.enable_proj}", file=sys.stderr, ) # Load local model if specified if args.model_path: try: model = VoxCPM( voxcpm_model_path=args.model_path, zipenhancer_model_path=zipenhancer_path, enable_denoiser=not args.no_denoiser, optimize=not args.no_optimize, device=args.device, lora_config=lora_config, lora_weights_path=lora_weights_path, ) print("Model loaded (local).", file=sys.stderr) return model except Exception as e: print(f"Failed to load model (local): {e}", file=sys.stderr) sys.exit(1) # Load from Hugging Face Hub try: model = VoxCPM.from_pretrained( hf_model_id=args.hf_model_id, load_denoiser=not args.no_denoiser, zipenhancer_model_id=zipenhancer_path, cache_dir=args.cache_dir, local_files_only=args.local_files_only, optimize=not args.no_optimize, device=args.device, lora_config=lora_config, lora_weights_path=lora_weights_path, ) print("Model loaded (from_pretrained).", file=sys.stderr) return model except Exception as e: print(f"Failed to load model (from_pretrained): {e}", file=sys.stderr) sys.exit(1) # ----------------------------- # Commands # ----------------------------- def _run_single(args, parser, *, text: str, output: str, prompt_text: str | None): output_path = validate_output_path(output) if args.prompt_audio: require_file_exists(args.prompt_audio, parser, "prompt audio file") if args.reference_audio: require_file_exists(args.reference_audio, parser, "reference audio file") model = load_model(args) audio_array = model.generate( text=text, prompt_wav_path=args.prompt_audio, prompt_text=prompt_text, reference_wav_path=args.reference_audio, cfg_value=args.cfg_value, inference_timesteps=args.inference_timesteps, normalize=args.normalize, denoise=args.denoise and (args.prompt_audio is not None or args.reference_audio is not None), ) import soundfile as sf sf.write(str(output_path), audio_array, model.tts_model.sample_rate) duration = len(audio_array) / model.tts_model.sample_rate print(f"Saved audio to: {output_path} ({duration:.2f}s)", file=sys.stderr) def cmd_design(args, parser): validate_design_args(args, parser) final_text = build_final_text(args.text, args.control) return _run_single( args, parser, text=final_text, output=args.output, prompt_text=None ) def cmd_clone(args, parser): prompt_text = validate_clone_args(args, parser) final_text = build_final_text(args.text, args.control) return _run_single( args, parser, text=final_text, output=args.output, prompt_text=prompt_text ) def cmd_validate(args, parser): from voxcpm.training.validate import ( print_validation_report, validate_manifest, ) manifest = str(require_file_exists(args.manifest, parser, "manifest file")) result = validate_manifest( manifest_path=manifest, sample_rate=args.sample_rate, max_samples=args.max_samples, verbose=args.verbose, ) print_validation_report(result, manifest) if not result.is_valid: sys.exit(1) def cmd_batch(args, parser): import soundfile as sf input_file = require_file_exists(args.input, parser, "input file") output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) with open(input_file, "r", encoding="utf-8") as f: texts = [line.strip() for line in f if line.strip()] if not texts: sys.exit("Error: Input file is empty") prompt_text = validate_batch_args(args, parser) model = load_model(args) prompt_audio_path = None if args.prompt_audio: prompt_audio_path = str( require_file_exists(args.prompt_audio, parser, "prompt audio file") ) reference_audio_path = None if args.reference_audio: reference_audio_path = str( require_file_exists(args.reference_audio, parser, "reference audio file") ) success_count = 0 for i, text in enumerate(texts, 1): try: final_text = build_final_text(text, args.control) audio_array = model.generate( text=final_text, prompt_wav_path=prompt_audio_path, prompt_text=prompt_text, reference_wav_path=reference_audio_path, cfg_value=args.cfg_value, inference_timesteps=args.inference_timesteps, normalize=args.normalize, denoise=args.denoise and (prompt_audio_path is not None or reference_audio_path is not None), ) output_file = output_dir / f"output_{i:03d}.wav" sf.write(str(output_file), audio_array, model.tts_model.sample_rate) duration = len(audio_array) / model.tts_model.sample_rate print(f"Saved: {output_file} ({duration:.2f}s)", file=sys.stderr) success_count += 1 except Exception as e: print(f"Failed on line {i}: {e}", file=sys.stderr) print(f"\nBatch finished: {success_count}/{len(texts)} succeeded", file=sys.stderr) # ----------------------------- # Parser # ----------------------------- def _add_common_generation_args(parser): parser.add_argument("--text", "-t", help="Text to synthesize") parser.add_argument( "--control", type=str, help="Control instruction for VoxCPM2 voice design/cloning", ) parser.add_argument( "--cfg-value", type=float, default=2.0, help="CFG guidance scale (float, recommended 1.0–3.0, default: 2.0)", ) parser.add_argument( "--inference-timesteps", type=int, default=10, help="Inference steps (int, recommended 4–30, default: 10)", ) parser.add_argument( "--normalize", action="store_true", help="Enable text normalization" ) def _add_prompt_reference_args(parser): parser.add_argument( "--prompt-audio", "-pa", help="Prompt audio file path (continuation mode, requires --prompt-text or --prompt-file)", ) parser.add_argument( "--prompt-text", "-pt", help="Text corresponding to the prompt audio" ) parser.add_argument( "--prompt-file", type=str, help="Text file corresponding to the prompt audio" ) parser.add_argument( "--reference-audio", "-ra", help="Reference audio for voice cloning (VoxCPM2 only)", ) parser.add_argument( "--denoise", action="store_true", help="Enable prompt/reference speech enhancement", ) def _add_model_args(parser): parser.add_argument("--model-path", type=str, help="Local VoxCPM model path") parser.add_argument( "--hf-model-id", type=str, default=DEFAULT_HF_MODEL_ID, help=f"Hugging Face repo id (default: {DEFAULT_HF_MODEL_ID})", ) parser.add_argument( "--device", type=str, default="auto", help="Runtime device: auto, cpu, mps, cuda, or cuda:N (default: auto)", ) parser.add_argument( "--cache-dir", type=str, help="Cache directory for Hub downloads" ) parser.add_argument( "--local-files-only", action="store_true", help="Disable network access" ) parser.add_argument( "--no-denoiser", action="store_true", help="Disable denoiser model loading" ) parser.add_argument( "--no-optimize", action="store_true", help="Disable model optimization during loading", ) parser.add_argument( "--zipenhancer-path", type=str, help="ZipEnhancer model id or local path (or env ZIPENHANCER_MODEL_PATH)", ) def _add_lora_args(parser): parser.add_argument("--lora-path", type=str, help="Path to LoRA weights") parser.add_argument( "--lora-r", type=int, default=32, help="LoRA rank (positive int, default: 32)" ) parser.add_argument( "--lora-alpha", type=int, default=16, help="LoRA alpha (positive int, default: 16)", ) parser.add_argument( "--lora-dropout", type=float, default=0.0, help="LoRA dropout rate (0.0–1.0, default: 0.0)", ) parser.add_argument( "--lora-disable-lm", action="store_true", help="Disable LoRA on LM layers" ) parser.add_argument( "--lora-disable-dit", action="store_true", help="Disable LoRA on DiT layers" ) parser.add_argument( "--lora-enable-proj", action="store_true", help="Enable LoRA on projection layers", ) def _build_parser(): parser = argparse.ArgumentParser( description="VoxCPM CLI - VoxCPM2-first voice design, cloning, and batch processing", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: voxcpm design --text "Hello world" --output out.wav voxcpm design --text "Hello world" --control "warm female voice" --output out.wav voxcpm clone --text "Hello" --reference-audio ref.wav --output out.wav voxcpm batch --input texts.txt --output-dir ./outs --reference-audio ref.wav """, ) subparsers = parser.add_subparsers(dest="command") design_parser = subparsers.add_parser( "design", help="Generate speech with VoxCPM2-first voice design" ) _add_common_generation_args(design_parser) _add_prompt_reference_args(design_parser) _add_model_args(design_parser) _add_lora_args(design_parser) design_parser.add_argument( "--output", "-o", required=True, help="Output audio file path" ) clone_parser = subparsers.add_parser( "clone", help="Clone a voice with reference/prompt audio" ) _add_common_generation_args(clone_parser) _add_prompt_reference_args(clone_parser) _add_model_args(clone_parser) _add_lora_args(clone_parser) clone_parser.add_argument( "--output", "-o", required=True, help="Output audio file path" ) batch_parser = subparsers.add_parser( "batch", help="Batch-generate one line per output file" ) batch_parser.add_argument( "--input", "-i", required=True, help="Input text file (one text per line)" ) batch_parser.add_argument( "--output-dir", "-od", required=True, help="Output directory" ) batch_parser.add_argument( "--control", type=str, help="Control instruction for VoxCPM2 voice design/cloning", ) _add_prompt_reference_args(batch_parser) batch_parser.add_argument( "--cfg-value", type=float, default=2.0, help="CFG guidance scale (float, recommended 1.0–3.0, default: 2.0)", ) batch_parser.add_argument( "--inference-timesteps", type=int, default=10, help="Inference steps (int, recommended 4–30, default: 10)", ) batch_parser.add_argument( "--normalize", action="store_true", help="Enable text normalization" ) _add_model_args(batch_parser) _add_lora_args(batch_parser) # Validate subcommand validate_parser = subparsers.add_parser( "validate", help="Validate a training data manifest (JSONL) before fine-tuning", ) validate_parser.add_argument( "--manifest", "-m", required=True, help="Path to JSONL training manifest" ) validate_parser.add_argument( "--sample-rate", type=int, default=16_000, help="Expected audio sample rate in Hz (default: 16000)", ) validate_parser.add_argument( "--max-samples", type=int, default=0, help="Maximum number of samples to validate (0 = all, default: 0)", ) validate_parser.add_argument( "--verbose", "-v", action="store_true", help="Print per-sample progress" ) # Legacy root arguments parser.add_argument("--input", "-i", help="Input text file (batch mode only)") parser.add_argument( "--output-dir", "-od", help="Output directory (batch mode only)" ) _add_common_generation_args(parser) parser.add_argument( "--output", "-o", help="Output audio file path (single or clone mode)" ) _add_prompt_reference_args(parser) _add_model_args(parser) _add_lora_args(parser) return parser def _dispatch_legacy(args, parser): warn_legacy_mode() if args.input and args.text: parser.error( "Use either batch mode (--input) or single mode (--text), not both." ) if args.input: if not args.output_dir: parser.error("Batch mode requires --output-dir") return cmd_batch(args, parser) if not args.text or not args.output: parser.error("Single-sample legacy mode requires --text and --output") if ( args.prompt_audio or args.prompt_text or args.prompt_file or args.reference_audio ): return cmd_clone(args, parser) return cmd_design(args, parser) # ----------------------------- # Entrypoint # ----------------------------- def main(): parser = _build_parser() args = parser.parse_args() if args.command == "validate": return cmd_validate(args, parser) validate_ranges(args, parser) if args.command == "design": if not args.text: parser.error("`design` requires --text") return cmd_design(args, parser) if args.command == "clone": if not args.text or not args.output: parser.error("`clone` requires --text and --output") return cmd_clone(args, parser) if args.command == "batch": return cmd_batch(args, parser) return _dispatch_legacy(args, parser) if __name__ == "__main__": main()