#!/usr/bin/env python3 from __future__ import annotations import argparse import json import sys from pathlib import Path import requests DEFAULT_BASE_URL = "http://localhost:7860" def die(message: str, code: int = 1) -> None: print(f"error: {message}", file=sys.stderr) raise SystemExit(code) def clean_base(url: str) -> str: return str(url or DEFAULT_BASE_URL).rstrip("/") def request(args, method: str, path: str, **kwargs): url = clean_base(args.base_url) + path try: resp = requests.request(method, url, timeout=args.timeout, **kwargs) except requests.RequestException as exc: die(f"{method} {url} failed: {exc}") if not resp.ok: detail = resp.text try: detail = resp.json().get("detail", detail) except Exception: pass die(f"{method} {url} returned HTTP {resp.status_code}: {detail}") return resp def get_json(args, path: str): return request(args, "GET", path).json() def post_json(args, path: str, payload: dict): return request(args, "POST", path, json=payload).json() def print_json(data) -> None: print(json.dumps(data, indent=2, ensure_ascii=False)) def print_table(rows: list[dict], columns: list[tuple[str, str]]) -> None: if not rows: print("(none)") return widths = {} for key, title in columns: widths[key] = max(len(title), *(len(str(r.get(key, ""))) for r in rows)) print(" ".join(title.ljust(widths[key]) for key, title in columns)) print(" ".join("-" * widths[key] for key, _title in columns)) for row in rows: print(" ".join(str(row.get(key, "")).ljust(widths[key]) for key, _title in columns)) def cmd_settings(args) -> None: if args.settings_cmd == "show": print_json(get_json(args, "/api/settings")) return current = get_json(args, "/api/settings") updates = { "whisper_url": args.whisper_url, "whisper_api_key": args.whisper_key, "tts_url": args.tts_url, "tts_api_key": args.tts_key, "tts_backend": args.tts_backend, "voice_design_url": args.voice_design_url, "voice_design_api_key": args.voice_design_key, "nvidia_router_url": args.nvidia_router_url, "nvidia_tts_url": args.nvidia_tts_url, "nvidia_asr_url": args.nvidia_asr_url, "nvidia_clone_url": args.nvidia_clone_url, "voices_scan_dir": args.voices_scan_dir, "output_dir": args.output_dir, } current.update({k: v for k, v in updates.items() if v is not None}) print_json(post_json(args, "/api/settings", current)) def cmd_voices(args) -> None: if args.voices_cmd == "list": voices = get_json(args, "/api/voices") if not args.all: voices = [v for v in voices if v.get("enabled") is not False] if args.json: print_json(voices) return rows = [{ "id": v.get("id", ""), "lang": v.get("language") or v.get("flag") or "", "gender": v.get("gender", ""), "type": v.get("file_type", ""), "duration": f"{float(v.get('duration') or 0):.1f}", "db": (v.get("loudness") or {}).get("dbfs", ""), "bench": (v.get("benchmark") or {}).get("elapsed_sec", ""), "ttfa": (v.get("benchmark") or {}).get("ttfa_ms", ""), "speed": (v.get("benchmark") or {}).get("speed", ""), "active": "yes" if v.get("enabled") is not False else "no", } for v in voices] print_table(rows, [("id", "Voice"), ("lang", "Lang"), ("gender", "Sex"), ("type", "Type"), ("duration", "Sec"), ("db", "dB"), ("bench", "Bench"), ("ttfa", "TTFA"), ("speed", "Speed"), ("active", "Active")]) elif args.voices_cmd in {"enable", "disable"}: print_json(post_json(args, "/api/voice/meta", {"voice_id": args.voice, "enabled": args.voices_cmd == "enable"})) elif args.voices_cmd == "delete": if not args.yes: die("delete needs --yes") print_json(request(args, "DELETE", f"/api/voice/{args.voice}").json()) elif args.voices_cmd == "rename": print_json(post_json(args, "/api/voice/rename", {"voice_id": args.voice, "new_id": args.new_id})) elif args.voices_cmd == "normalize": payload = {"voice_id": args.voice} if args.target_dbfs is not None: payload["target_dbfs"] = args.target_dbfs print_json(post_json(args, "/api/voice/normalize", payload)) elif args.voices_cmd == "normalize-active": print_json(post_json(args, "/api/voices/normalize-active", {})) elif args.voices_cmd == "calculate-db": print_json(post_json(args, "/api/voices/calculate-db", {})) elif args.voices_cmd == "benchmark": payload = { "active_only": not args.all, "limit": args.limit, "text": args.text, } if args.voice: payload["voice_id"] = args.voice print_json(post_json(args, "/api/voices/benchmark", payload)) elif args.voices_cmd == "sync": print_json(post_json(args, "/api/voices/sync-folders", {})) elif args.voices_cmd == "restart-tts": print_json(post_json(args, "/api/tts/restart", {})) def load_routes(args) -> list[dict]: return get_json(args, "/api/tts-routes").get("routes", []) def save_routes(args, routes: list[dict]) -> None: print_json(post_json(args, "/api/tts-routes", {"routes": routes})) def cmd_routes(args) -> None: routes = load_routes(args) if args.routes_cmd == "list": if args.json: print_json(routes) return rows = [] for i, r in enumerate(routes): rows.append({ "idx": i, "on": "yes" if r.get("enabled", True) else "no", "app": r.get("app", "*"), "input": r.get("input_voice", "*"), "lang": r.get("language", "*"), "output": r.get("output_voice", ""), "before": r.get("before_sound", ""), "after": r.get("after_sound", ""), }) print_table(rows, [("idx", "#"), ("on", "On"), ("app", "App"), ("input", "Input"), ("lang", "Lang"), ("output", "Output"), ("before", "Before"), ("after", "After")]) elif args.routes_cmd == "add": routes.append({ "enabled": not args.disabled, "app": args.app, "input_voice": args.input_voice, "language": args.language.upper(), "output_voice": args.output_voice, "before_sound": args.before_sound or "", "after_sound": args.after_sound or "", }) save_routes(args, routes) elif args.routes_cmd == "delete": if args.index < 0 or args.index >= len(routes): die(f"route index {args.index} out of range") del routes[args.index] save_routes(args, routes) elif args.routes_cmd == "clear": if not args.yes: die("clear needs --yes") save_routes(args, []) elif args.routes_cmd == "openwebui-defaults": if args.en: routes.append({"enabled": True, "app": "Open WebUI", "input_voice": "default", "language": "EN", "output_voice": args.en}) if args.de: routes.append({"enabled": True, "app": "Open WebUI", "input_voice": "default", "language": "DE", "output_voice": args.de}) if not args.en and not args.de: die("provide --en and/or --de output voice") save_routes(args, routes) def cmd_tts(args) -> None: if args.tts_cmd == "voices": print_json(get_json(args, "/v1/audio/voices")) return payload = { "model": args.model, "voice": args.voice, "input": args.text, "response_format": args.format, } resp = request(args, "POST", "/v1/audio/speech", json=payload) out = Path(args.out) out.write_bytes(resp.content) print(f"wrote {out}") routed = resp.headers.get("x-tts-voice-routed") requested = resp.headers.get("x-tts-voice-requested") if routed and requested and routed != requested: print(f"routed {requested} -> {routed} ({resp.headers.get('x-tts-route-language', '')})") def cmd_create_voice(args) -> None: path = Path(args.file) if not path.exists(): die(f"file not found: {path}") with path.open("rb") as fh: upload = request(args, "POST", "/api/upload", files={"file": (path.name, fh)}).json() start = args.start end = args.end if args.end is not None else upload.get("duration", 0) processed = post_json(args, "/api/process", {"id": upload["id"], "start": start, "end": end}) saved = post_json(args, "/api/save", { "id": processed["id"], "voice_id": args.voice_id, "transcript": args.transcript or "", }) print_json({"upload": upload, "processed": processed, "saved": saved}) def cmd_presets(args) -> None: presets = get_json(args, "/api/voice-design-presets") if args.presets_cmd == "list": print_json(presets) elif args.presets_cmd == "delete": presets.pop(args.name, None) print_json(post_json(args, "/api/voice-design-presets", presets)) elif args.presets_cmd == "save": presets[args.name] = { "description": args.description, "language": args.language, "gender": args.gender, "sample_text": args.sample_text, } print_json(post_json(args, "/api/voice-design-presets", presets)) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="ttsvc", description="Command line control for TTS Voice Creator - Clone and Design.", ) parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=f"Creator app URL, default: {DEFAULT_BASE_URL}") parser.add_argument("--timeout", type=float, default=120, help="HTTP timeout in seconds") sub = parser.add_subparsers(dest="cmd", required=True) settings = sub.add_parser("settings", help="Show or update app settings") settings_sub = settings.add_subparsers(dest="settings_cmd", required=True) settings_sub.add_parser("show", help="Print current settings").set_defaults(func=cmd_settings) settings_set = settings_sub.add_parser("set", help="Update selected settings") settings_set.add_argument("--whisper-url") settings_set.add_argument("--whisper-key") settings_set.add_argument("--tts-url") settings_set.add_argument("--tts-key") settings_set.add_argument("--tts-backend", choices=["openai", "localai", "pocket", "nvidia_magpie"]) settings_set.add_argument("--voice-design-url") settings_set.add_argument("--voice-design-key") settings_set.add_argument("--nvidia-router-url") settings_set.add_argument("--nvidia-tts-url") settings_set.add_argument("--nvidia-asr-url") settings_set.add_argument("--nvidia-clone-url") settings_set.add_argument("--voices-scan-dir") settings_set.add_argument("--output-dir") settings_set.set_defaults(func=cmd_settings) voices = sub.add_parser("voices", help="Manage cloned voices") voices_sub = voices.add_subparsers(dest="voices_cmd", required=True) voices_list = voices_sub.add_parser("list", help="List voices") voices_list.add_argument("--all", action="store_true", help="Include disabled voices") voices_list.add_argument("--json", action="store_true", help="Print raw JSON") voices_list.set_defaults(func=cmd_voices) for name in ("enable", "disable", "delete", "normalize"): p = voices_sub.add_parser(name, help=f"{name.title()} a voice") p.add_argument("voice") if name == "delete": p.add_argument("--yes", action="store_true", help="Confirm deletion") if name == "normalize": p.add_argument("--target-dbfs", type=float) p.set_defaults(func=cmd_voices) rename = voices_sub.add_parser("rename", help="Rename a voice package") rename.add_argument("voice") rename.add_argument("new_id") rename.set_defaults(func=cmd_voices) benchmark = voices_sub.add_parser("benchmark", help="Benchmark voice synthesis speed") benchmark.add_argument("--all", action="store_true", help="Include disabled voices") benchmark.add_argument("--limit", type=int, default=0, help="Stop after N voices") benchmark.add_argument("--voice", default="", help="Benchmark one voice ID") benchmark.add_argument("--text", default="", help="Benchmark sentence") benchmark.set_defaults(func=cmd_voices) for name in ("normalize-active", "calculate-db", "sync", "restart-tts"): voices_sub.add_parser(name, help=name.replace("-", " ").title()).set_defaults(func=cmd_voices) routes = sub.add_parser("routes", help="Manage /v1 TTS routing rules") routes_sub = routes.add_subparsers(dest="routes_cmd", required=True) routes_list = routes_sub.add_parser("list", help="List routing rules") routes_list.add_argument("--json", action="store_true") routes_list.set_defaults(func=cmd_routes) routes_add = routes_sub.add_parser("add", help="Add one route") routes_add.add_argument("--app", default="Open WebUI") routes_add.add_argument("--input-voice", default="default") routes_add.add_argument("--language", default="*", help="*, AUTO, EN, DE, FR, ES, IT, PT, NL, PL") routes_add.add_argument("--output-voice", required=True) routes_add.add_argument("--before-sound", help="Audio path inside voices dir to prepend, e.g. sounds/start.wav") routes_add.add_argument("--after-sound", help="Audio path inside voices dir to append, e.g. sounds/end.wav") routes_add.add_argument("--disabled", action="store_true") routes_add.set_defaults(func=cmd_routes) routes_delete = routes_sub.add_parser("delete", help="Delete route by index") routes_delete.add_argument("index", type=int) routes_delete.set_defaults(func=cmd_routes) routes_clear = routes_sub.add_parser("clear", help="Delete all routes") routes_clear.add_argument("--yes", action="store_true") routes_clear.set_defaults(func=cmd_routes) routes_examples = routes_sub.add_parser("openwebui-defaults", help="Add default EN/DE Open WebUI routes") routes_examples.add_argument("--en") routes_examples.add_argument("--de") routes_examples.set_defaults(func=cmd_routes) tts = sub.add_parser("tts", help="Use the OpenAI-compatible TTS proxy") tts_sub = tts.add_subparsers(dest="tts_cmd", required=True) tts_sub.add_parser("voices", help="List /v1 voices and route aliases").set_defaults(func=cmd_tts) speak = tts_sub.add_parser("speak", help="Generate speech through /v1/audio/speech") speak.add_argument("--voice", required=True) speak.add_argument("--text", required=True) speak.add_argument("--out", default="speech.wav") speak.add_argument("--model", default="tts-1") speak.add_argument("--format", default="wav") speak.set_defaults(func=cmd_tts) create = sub.add_parser("create-voice", help="Upload, trim, and save a voice from an audio/video file") create.add_argument("--file", required=True) create.add_argument("--voice-id", required=True) create.add_argument("--transcript", default="") create.add_argument("--start", type=float, default=0) create.add_argument("--end", type=float) create.set_defaults(func=cmd_create_voice) presets = sub.add_parser("presets", help="Manage Voice Design presets") presets_sub = presets.add_subparsers(dest="presets_cmd", required=True) presets_sub.add_parser("list", help="List presets").set_defaults(func=cmd_presets) preset_save = presets_sub.add_parser("save", help="Create or replace a preset") preset_save.add_argument("name") preset_save.add_argument("--description", required=True) preset_save.add_argument("--language", default="Auto") preset_save.add_argument("--gender", default="N") preset_save.add_argument("--sample-text", default="") preset_save.set_defaults(func=cmd_presets) preset_delete = presets_sub.add_parser("delete", help="Delete a preset") preset_delete.add_argument("name") preset_delete.set_defaults(func=cmd_presets) return parser def main(argv: list[str] | None = None) -> None: parser = build_parser() args = parser.parse_args(argv) args.func(args) if __name__ == "__main__": main()