tts-voice-creator-clone-and.../ttsvc_cli.py
mARTin-B78 631ca16290 Studio Notebook redesign: warm cream theme, sidebar, multi-file static structure
- New light UI: fixed 220px sidebar, single scrolling page, 8 named sections
- Static files split by concern: style.css, app.js, loader.js, nav.js
- Each page section is its own partial in static/sections/s-*.html
- loader.js fetches all section partials in parallel, then loads app.js and nav.js
- All original functionality, element IDs, and API endpoints preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 19:53:01 +02:00

377 lines
16 KiB
Python
Executable File

#!/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()