- 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>
377 lines
16 KiB
Python
Executable File
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()
|