# src/voice_gen/cli.py """argparse-based CLI for voice-gen.""" from __future__ import annotations import argparse import json import shutil import subprocess import sys from datetime import datetime, timezone from pathlib import Path from .registry import VoiceRegistry _MMX_CONFIG_PATH = Path.home() / ".mmx" / "config.json" _oauth_token_cache: str | None = None def _load_oauth_token() -> str: """Load the OAuth access token from ~/.mmx/config.json (cached).""" global _oauth_token_cache if _oauth_token_cache is None: if not _MMX_CONFIG_PATH.exists(): raise FileNotFoundError( f"mmx config not found at {_MMX_CONFIG_PATH}; " "run `mmx auth login` first" ) data = json.loads(_MMX_CONFIG_PATH.read_text(encoding="utf-8")) _oauth_token_cache = data["oauth"]["access_token"] return _oauth_token_cache def _get_mmx_resource_url() -> str: """Return the mmx API base URL from the user's config.""" if not _MMX_CONFIG_PATH.exists(): return "https://api.minimaxi.com" data = json.loads(_MMX_CONFIG_PATH.read_text(encoding="utf-8")) return data.get("oauth", {}).get("resource_url", "https://api.minimaxi.com") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="voice-gen", description="Batch TTS via mmx CLI with custom voice clones.", ) sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("init", help="Create voices.json, scripts/, output/ in CWD") voices = sub.add_parser("voices", help="Manage the voice registry") voices_sub = voices.add_subparsers(dest="voices_command", required=True) list_p = voices_sub.add_parser("list", help="List registered voices (default: local)") list_p.add_argument("scope", nargs="?", default="local", choices=["local", "server"], help="Which voices to list (default: local)") add_p = voices_sub.add_parser("add", help="Upload an audio file; friendly name is derived from the filename stem") add_p.add_argument("audio", help="Path to the reference audio file") pull_p = voices_sub.add_parser("pull", help="Sync voices from server into the local registry") pull_p.add_argument("--auto", action="store_true", help="Always auto-derive friendly name from filename (default if non-TTY)") pull_p.add_argument("--interactive", action="store_true", help="Always prompt for each friendly name (default if TTY)") pull_p.add_argument("--force", action="store_true", help="Overwrite local entries that have the same name") pull_p.add_argument("--dry-run", action="store_true", help="Show what would be added/changed without modifying voices.json") voices_sub.add_parser("cleanup", help="Delete duplicate voices that share the same source filename") gen = sub.add_parser("gen", help="Synthesize audio from a directory of .md scripts") gen.add_argument("--voice", required=True, help="Voice name from registry") gen.add_argument("input", help="Input directory containing .md files") gen.add_argument("--out", default="output", help="Output directory (default: output)") gen.add_argument("--format", default="mp3", choices=["mp3", "wav", "flac", "pcm", "opus"]) return parser def _registry() -> VoiceRegistry: return VoiceRegistry.load(Path("voices.json")) def _upload_voice_file(path: Path, purpose: str = "voice_clone") -> dict: """Upload a file to MiniMax storage via direct API call. Bypasses `mmx file upload` because mmx CLI v1.0.16 POSTs to the wrong endpoint (`/v1/files`) and gets a 404. The correct endpoint is `/v1/files/upload` (multipart form). """ from .errors import MmxError token = _load_oauth_token() base_url = _get_mmx_resource_url() curl = shutil.which("curl") if not curl: raise MmxError( exit_code=1, stderr="curl not found on PATH; required for direct API upload", command="curl", ) url = f"{base_url}/v1/files/upload" cmd = [ curl, "-s", "-X", "POST", url, "-H", f"Authorization: Bearer {token}", "-F", f"file=@{path}", "-F", f"purpose={purpose}", ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise MmxError( exit_code=result.returncode, stderr=result.stderr, command=f"curl ... {url}", ) try: data = json.loads(result.stdout) except json.JSONDecodeError as e: raise MmxError( exit_code=1, stderr=f"invalid JSON from upload API: {result.stdout[:500]}", command=f"curl ... {url}", ) if "file" not in data or "file_id" not in data["file"]: raise MmxError( exit_code=1, stderr=f"unexpected response from upload API: {data}", command=f"curl ... {url}", ) return { "file_id": data["file"]["file_id"], "filename": data["file"].get("filename", path.name), } def _delete_remote_file(file_id: int) -> bool: """Run `mmx file delete`. Raises MmxError on failure.""" from .errors import MmxError cmd = [ "mmx", "file", "delete", "--non-interactive", "--quiet", "--file-id", str(file_id), ] from .synthesizer import _run_mmx result = _run_mmx(cmd) if result.returncode != 0: raise MmxError(exit_code=result.returncode, stderr=result.stderr, command=" ".join(cmd)) return True def _clone_voice_file(file_id: int, voice_id: str) -> None: """Register an uploaded file as a voice clone via /v1/voice_clone. After this call, `voice_id` (the user's friendly name) is usable in the synthesis endpoint (`/v1/t2a_v2`). Without this step, the file_id alone cannot be used as a voice_id 鈥?the API will respond with "voice id not exist". """ from .errors import MmxError token = _load_oauth_token() base_url = _get_mmx_resource_url() curl = shutil.which("curl") if not curl: raise MmxError( exit_code=1, stderr="curl not found on PATH; required for direct API call", command="curl", ) url = f"{base_url}/v1/voice_clone" payload = json.dumps({ "file_id": file_id, "voice_id": voice_id, "model": "speech-2.8-hd", "text": "test", "need_noise_reduction": False, "need_volume_normalization": False, }) cmd = [ curl, "-s", "-X", "POST", url, "-H", f"Authorization: Bearer {token}", "-H", "Content-Type: application/json", "-d", payload, ] result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0: raise MmxError( exit_code=result.returncode, stderr=result.stderr, command=f"curl ... {url}", ) try: data = json.loads(result.stdout) except json.JSONDecodeError as e: raise MmxError( exit_code=1, stderr=f"invalid JSON from voice_clone API: {result.stdout[:500]}", command=f"curl ... {url}", ) resp = data.get("base_resp", {}) if resp.get("status_code") != 0: raise MmxError( exit_code=1, stderr=f"voice_clone failed: status_code={resp.get('status_code')} " f"msg={resp.get('status_msg')} raw={data}", command=f"curl ... {url}", ) def cmd_init(_args: argparse.Namespace) -> int: cwd = Path.cwd() (cwd / "scripts").mkdir(exist_ok=True) (cwd / "output").mkdir(exist_ok=True) registry_path = cwd / "voices.json" if not registry_path.exists(): registry_path.write_text( json.dumps({"voices": {}}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) print(f"initialized in {cwd}") return 0 def cmd_voices_list(args: argparse.Namespace) -> int: if args.scope == "server": return _list_server_voices() reg = _registry() names = reg.list_names() if not names: print("(no voices registered; run `voice-gen voices add