diff options
Diffstat (limited to 'tools/make_audiocpp_server_json.py')
| -rwxr-xr-x | tools/make_audiocpp_server_json.py | 1029 |
1 files changed, 736 insertions, 293 deletions
diff --git a/tools/make_audiocpp_server_json.py b/tools/make_audiocpp_server_json.py index fb16a43..3446428 100755 --- a/tools/make_audiocpp_server_json.py +++ b/tools/make_audiocpp_server_json.py @@ -8,15 +8,24 @@ converter itself is family-agnostic (it detects the family of the selected entry from ``GET /v1/models`` at startup), so any TTS family listed in the catalog works without further changes. -By default the tool runs as a minimal full-screen TUI (curses): a file -browser for the audio.cpp checkout and the .wav directory, an expandable -checkbox tree of model families and their installable packages, and a -series of single-question screens for the server settings. Pass ``--notui`` -to use the classic numbered line prompts instead (also selected +By default the tool runs as a colorful full-screen TUI (curses): every +screen is a centered DOS-style dialog on a black desktop — a file +browser for the audio.cpp checkout and the .wav directory, an +expandable checkbox tree of model families and their installable +packages, centered single-question screens for the server settings, +and Yes/No buttons for every confirmation. In the checkout browser, +pressing Enter (or Right) on a subdirectory named ``audio.cpp`` that +already contains ``model_specs/`` picks it directly, skipping the +``[ Use this directory ]`` step; pressing Esc on the overwrite +confirmation then returns to the browser inside that checkout (with +the auto-pick disabled), instead of aborting the wizard. Esc on any +other wizard screen falls back to the previous screen group (only the +first screen, the checkout browser, exits on Esc). Pass ``--notui`` to +use the classic numbered line prompts instead (also selected automatically when stdin/stdout is not a terminal, or when curses is -unavailable such as on Windows without ``windows-curses``). Every value -can also be supplied as a command-line flag, which skips the corresponding -screen or prompt. +unavailable such as on Windows without ``windows-curses``). Every +value can also be supplied as a command-line flag, which skips the +corresponding screen or prompt. Each family is hosted through its recommended package by default; the TUI tree always lists every installable package (distinct ``target_directory`` @@ -24,7 +33,12 @@ values) as checkboxes, while ``--all-packages`` in prompt mode offers a per-family package checklist (and pre-expands every family in the TUI). Packages whose name marks them as voice-design models are asked whether to host them with task "vdes" (describe the voice with ``--instructions``) or -plain "tts". +plain "tts". All families are treated equally and listed alphabetically. + +The .wav directory browser (and the prompt default) starts in the single +directory that directly contains .wav files across the audio.cpp checkout +and the tts-audiobook-generator root, if exactly one exists; the +generator's ``output/`` directory is never offered. Cloning reference .wav files (``--wavs DIR``) are transcribed with a local Whisper backend (faster_whisper or whisper) and published as a server-level @@ -50,19 +64,29 @@ with its resolved absolute path if it does not exist. server.json is written into the audio.cpp checkout by default (next to model_specs/). If that file already exists you are prompted [Y/n] before overwriting; answering "n" writes server.json in the current working -directory instead. +directory instead (in the TUI, Esc on that prompt returns to the +checkout browser rather than aborting). After a successful run the +console output is the written file plus one copy-pasteable +model_manager_v2.py install command per hosted model; you are also +asked whether to run those downloads automatically. --audiocpp-dir defaults to a detected audio.cpp checkout (the AUDIOCPP_DIR environment variable, or an ``audio.cpp`` directory next to or above the current working directory); if none is found it is asked interactively. The checkout must contain a ``model_specs/`` directory. A leading ``~`` in a path argument or prompt answer is expanded. + +--backend is the inference backend audiocpp_server was built for. When the +checkout contains a build directory (``build/<platform>-<backend>-<type>`` +with a built ``bin/audiocpp_server``), that backend is auto-detected, +selected by default and marked ``[auto-detected]`` in the menu. """ import argparse import json import os import re +import subprocess import sys import urllib.parse from pathlib import Path @@ -78,27 +102,42 @@ DEFAULT_HOST = "127.0.0.1" FALLBACK_PORT = 8080 CONFIG_PATH = Path(__file__).resolve().parent.parent / "converter" / "config.py" +# The tts-audiobook-generator checkout root (where audiobook.py lives), used +# to default the .wav directory browser. The audio.cpp checkout is detected +# separately (see detect_audiocpp_dir). +TTS_ROOT = Path(__file__).resolve().parent.parent +# Output directory of tts-audiobook-generator; never offered as a .wav source. +TTS_OUTPUT_DIR = "output" + BACKENDS = ("cuda", "vulkan", "hip", "cpu") PROMPT_TEXT_FILENAME = "prompt_text" TASK_TTS = "tts" TASK_VDES = "vdes" +# Sentinel returned by tui.confirm (via its cancel_value) when the user +# presses Esc on an overwrite prompt to go back to the checkout browser +# instead of aborting the wizard. +_GO_BACK = object() + + +class _GoBack(Exception): + """Raised inside the TUI wizard to fall back to the previous screen group. + + Every wizard widget is passed ``back_value=_GO_BACK`` so Esc returns the + sentinel instead of aborting; pickers and confirmations that call into + callbacks (task/id pickers, the transcription plan, the download prompt) + convert that sentinel into this exception so the enclosing step can catch + it and step back. Only the first screen (the checkout browser) lets Esc + abort the whole wizard. + """ + # Package names that mark a voice-design model (hosted with task "vdes"). DESIGN_PACKAGE_RE = re.compile(r"voice[\s_\-]?design", re.IGNORECASE) -# Families explicitly tested with this converter, in display order. These are -# listed first in the checklist and marked "[tested]"; every other TTS family -# in the catalog is offered too through the converter's generic profile. -TESTED_FAMILIES = ( - "qwen3_tts", - "higgs_audio_tts", - "voxcpm2", - "index_tts2", -) - -# Short, friendly default entry ids for tested families. Other families derive -# an id from their family name (see default_model_id). +# Short, friendly default entry ids for selected families. Other families +# derive an id from their family name (see default_model_id). All families +# are listed equally, in alphabetical order. PREFERRED_IDS = { "qwen3_tts": "qwen", "higgs_audio_tts": "higgs", @@ -172,6 +211,127 @@ def find_wav_files(input_dir: Path) -> list: ) +def _count_wavs(directory: Path) -> int: + """Count the .wav files in DIRECTORY (0 when it cannot be read).""" + try: + return sum(1 for path in directory.iterdir() + if path.is_file() and path.suffix.lower() == ".wav") + except OSError: + return 0 + + +def detect_wav_dir(audiocpp_dir: Path, tts_root: Path) -> Optional[Path]: + """Find a unique directory that directly contains .wav files. + + Looks shallowly (the root itself and its immediate subdirectories) in + both the audio.cpp checkout and the tts-audiobook-generator root (where + audiobook.py lives), since clone reference .wavs commonly live in either. + The tts-audiobook-generator ``output/`` directory is excluded. When + exactly one candidate is found it is returned (as a starting directory + for the .wav browser); when none or several are found None is returned + so the caller falls back to its default start location. + """ + candidates: List[Path] = [] + seen: Set[Path] = set() + + def consider(directory: Path) -> None: + try: + resolved = directory.resolve() + except OSError: + return + if resolved in seen: + return + seen.add(resolved) + if _count_wavs(directory) > 0: + candidates.append(directory) + + for root in (audiocpp_dir, tts_root): + if not root.is_dir(): + continue + consider(root) + try: + children = sorted(root.iterdir(), key=lambda p: p.name.lower()) + except OSError: + continue + for child in children: + if not child.is_dir() or child.name.startswith("."): + continue + # Exclude the tts-audiobook-generator output directory. + if root == tts_root and child.name == TTS_OUTPUT_DIR: + continue + consider(child) + + if len(candidates) == 1: + return candidates[0] + return None + + +def _wav_dir_info(directory: Path) -> Tuple[str, str]: + """TUI status describing the directory listed in the wav browser.""" + count = _count_wavs(directory) + if count: + wavs = ".wav" if count == 1 else ".wavs" + return (f"{count} {wavs} found in this directory. Press Enter.", + "ok") + return ("No .wav files found in this directory", "warn") + + +def _wav_dir_preview(directory: Path) -> Tuple[str, str]: + """TUI status describing a highlighted subdirectory in the wav browser.""" + count = _count_wavs(directory) + if count: + wavs = ".wav" if count == 1 else ".wavs" + return (f"{count} {wavs}", "ok") + return ("no .wav files", "info") + + +def _resolve_audiocpp_root(directory: Path) -> Optional[Path]: + """Return the audio.cpp checkout root for DIRECTORY, or None. + + Accepts either the checkout root itself (it must contain a + ``model_specs`` directory) or the ``model_specs`` directory inside + it (the parent is used), so the file browser cannot pick the wrong + one of the two. + """ + if (directory / "model_specs").is_dir(): + return directory + if directory.name == "model_specs" and directory.is_dir(): + return directory.parent + return None + + +def _audiocpp_root_status(directory: Path) -> Tuple[str, str]: + """TUI status describing the directory listed in the checkout browser.""" + if _resolve_audiocpp_root(directory) is not None: + return ("model_specs/ found here", "ok") + return ("No model_specs/ directory here", "warn") + + +def _audiocpp_root_preview(directory: Path) -> Optional[Tuple[str, str]]: + """TUI status for a highlighted subdirectory in the checkout browser.""" + if (directory / "model_specs").is_dir(): + return ("contains model_specs/", "ok") + return None + + +def _checkout_auto_select(entry: Path) -> Optional[Path]: + """Auto-accept a highlighted checkout in the TUI browser. + + A subdirectory named ``audio.cpp`` that already contains a + ``model_specs`` directory is the audio.cpp checkout root, so it is + accepted immediately on Enter/Right (as if ``[ Use this directory ]`` + had been pressed) instead of being descended into. Anything else + returns None so the user keeps browsing. This is only consulted + while auto-accepting is still enabled; after the user presses Esc to + go back, the browser is restarted inside the previously accepted + checkout and this callback is no longer passed, so a wrong guess can + be corrected. + """ + if entry.name == "audio.cpp" and (entry / "model_specs").is_dir(): + return entry + return None + + def ask(prompt: str, default: Optional[str] = None) -> Optional[str]: """Prompt for a free-text value with a default; EOF returns the default.""" suffix = f" [{default}]" if default is not None else "" @@ -267,15 +427,43 @@ def ask_checklist(title: str, options: list, default: Set[str]) -> Set[str]: print(f"Please enter comma-separated numbers between 1 and {len(options)}.") -def ask_backend() -> str: +# Backend display order, with short descriptions. The backend name is padded +# so the descriptions' dashes line up in the menu. +_BACKEND_DESCRIPTIONS = ( + ("cuda", "NVIDIA GPUs (fastest)"), + ("vulkan", "cross-vendor GPU"), + ("hip", "AMD GPUs"), + ("cpu", "no GPU required"), +) + + +def _backend_options(detected: Optional[str] = None + ) -> Tuple[List[Tuple[str, str]], int]: + """Build the aligned backend menu options and the default index. + + The backend names are padded to a common width so the ``-`` dashes + before the descriptions line up. When DETECTED matches one of the + options, that option gets ``[auto-detected]`` appended and is the + default (cursor/start) selection; otherwise the first option is the + default as before. Returns (options, default_index). + """ + width = max(len(name) for name, _ in _BACKEND_DESCRIPTIONS) + options: List[Tuple[str, str]] = [] + default_index = 0 + for index, (name, desc) in enumerate(_BACKEND_DESCRIPTIONS): + label = f"{name.ljust(width)} - {desc}" + if detected == name: + label += " [auto-detected]" + default_index = index + options.append((label, name)) + return options, default_index + + +def ask_backend(detected: Optional[str] = None) -> str: + options, default_index = _backend_options(detected) return ask_menu( "Which inference backend was audiocpp_server built for?", - [ - ("cuda - NVIDIA GPUs (fastest)", "cuda"), - ("vulkan - cross-vendor GPU", "vulkan"), - ("hip - AMD GPUs", "hip"), - ("cpu - no GPU required", "cpu"), - ]) + options, default_index=default_index + 1) def config_port() -> int: @@ -384,6 +572,53 @@ def detect_audiocpp_dir() -> Optional[Path]: return None +# audio.cpp build directories are named ``<platform>-<backend>-<type>`` (e.g. +# ``linux-cuda-release``, ``windows-vulkan-debug``, ``macos-metal-release``) +# and the built server lands in ``<that>/bin/audiocpp_server``. The Metal +# macOS backend is reported as "cpu" here since it is not a separate +# --backend choice for audiocpp_server. +_BACKEND_TOKEN_RE = re.compile(r"-(cuda|vulkan|hip|cpu|metal)(?:-|$)") + + +def detect_backend(audiocpp_dir: Path) -> Optional[str]: + """Best-effort detection of the backend audiocpp_server was built for. + + Scans ``audiocpp_dir/build/*`` for build directories that contain a + built ``bin/audiocpp_server`` (``.exe`` allowed on Windows) and reads + the backend token out of the directory name (``-cuda-``, ``-vulkan-``, + ``-hip-`` or ``-cpu-``; ``-metal-`` is mapped to ``cpu``). Returns the + backend only when exactly one distinct backend was built, so a checkout + with builds for several backends does not silently pick one. Returns + None when there is no ``build/`` directory, no built server, or more + than one distinct backend. + """ + build_root = audiocpp_dir / "build" + if not build_root.is_dir(): + return None + backends: Set[str] = set() + try: + build_dirs = sorted(build_root.iterdir(), + key=lambda p: p.name.lower()) + except OSError: + return None + for build_dir in build_dirs: + if not build_dir.is_dir(): + continue + server = build_dir / "bin" / "audiocpp_server" + if not server.exists(): + server_exe = build_dir / "bin" / "audiocpp_server.exe" + if not server_exe.exists(): + continue + match = _BACKEND_TOKEN_RE.search(build_dir.name.lower()) + if not match: + continue + token = match.group(1) + backends.add("cpu" if token == "metal" else token) + if len(backends) == 1: + return next(iter(backends)) + return None + + def _default_package(packages: List[dict]) -> Optional[dict]: """Pick the default package from a list of packages. @@ -407,8 +642,8 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]: Each returned entry has: family, display_name, description, languages, clone_capable, packages (the full list from the spec), install_id (recommended package id), default_path (``models/<target_directory>``), - tested, and preferred_id. Tested families come first (in TESTED_FAMILIES - order), the rest follow alphabetically by display name. + and preferred_id. All families are treated equally and listed in + alphabetical order by display name. """ specs_dir = audiocpp_dir / "model_specs" if not specs_dir.is_dir(): @@ -444,17 +679,11 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]: "packages": packages, "install_id": package.get("id") or family, "default_path": f"models/{target_directory}", - "tested": family in TESTED_FAMILIES, "preferred_id": default_model_id(family), }) - def sort_key(entry: dict) -> tuple: - family = entry["family"] - if family in TESTED_FAMILIES: - return (0, TESTED_FAMILIES.index(family), "") - return (1, 0, entry["display_name"].lower()) - - entries.sort(key=sort_key) + # All families are treated equally: alphabetical by display name. + entries.sort(key=lambda entry: entry["display_name"].lower()) return entries @@ -708,9 +937,7 @@ def print_empty_transcript_warning(transcripts: Dict[str, str]) -> None: def _apply_port_sync(port: int, accepted: bool) -> None: """Write the port into converter/config.py, or report when declined.""" if accepted: - if update_config_api_url_port(port): - print(f"[OK] Updated AUDIOCPP_API_URL in {CONFIG_PATH}") - else: + if not update_config_api_url_port(port): print(f"[WARNING] Could not update {CONFIG_PATH}; edit " "AUDIOCPP_API_URL by hand so audiobook.py uses the " "new port") @@ -720,9 +947,14 @@ def _apply_port_sync(port: int, accepted: bool) -> None: def _ask_host_port_backend_lazy(args: argparse.Namespace, - default_lazy: bool + default_lazy: bool, + detected_backend: Optional[str] = None ) -> Tuple[str, int, str, bool]: - """Ask for (or take from flags) the shared server settings.""" + """Ask for (or take from flags) the shared server settings. + + DETECTED_BACKEND (from detect_backend) is offered as the default backend + selection when --backend is not given. + """ host = args.host if args.host else ask("Bind host", DEFAULT_HOST) port = args.port if args.port is not None else ask_port(config_port()) if port != config_port(): @@ -731,7 +963,8 @@ def _ask_host_port_backend_lazy(args: argparse.Namespace, _apply_port_sync(port, True) else: _apply_port_sync(port, False) - backend = args.backend if args.backend else ask_backend() + backend = args.backend if args.backend else \ + ask_backend(detected_backend) lazy_load = args.lazy_load or ask_bool( "Load models lazily (on first use instead of at startup)", default_lazy) return host, port, backend, lazy_load @@ -742,7 +975,10 @@ def _decide_transcription(wav_files: list, existing: Dict[str, str], confirm: Callable[[str, bool], bool]) -> dict: """Decide which voices to transcribe; CONFIRM asks the plan questions. - Returns a plan dict: {"mode": "all"|"missing"|"keep", "missing": [...]}. + Returns a plan dict: {"mode": "all"|"missing"|"keep", "missing": + [...], "existing": {...}} — "existing" carries the prompt_text + mapping read while deciding, so the caller can reuse it instead of + reading the file again. """ mode = "all" missing: List[Path] = [] @@ -760,7 +996,7 @@ def _decide_transcription(wav_files: list, existing: Dict[str, str], mode = "missing" else: mode = "all" - return {"mode": mode, "missing": missing} + return {"mode": mode, "missing": missing, "existing": existing} def _transcribe(args: argparse.Namespace, include_clone: bool, @@ -771,7 +1007,8 @@ def _transcribe(args: argparse.Namespace, include_clone: bool, Returns the mapping and a flag indicating whether it should be written to prompt_text (False when an existing, complete prompt_text is kept as-is). When PLAN is given (pre-collected by the TUI) no further questions are - asked; otherwise the plan is decided with the line prompts. + asked and the prompt_text mapping it already read is reused; otherwise + the plan is decided with the line prompts. """ if not include_clone: print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family " @@ -785,13 +1022,14 @@ def _transcribe(args: argparse.Namespace, include_clone: bool, return {}, False prompt_path = args.input_dir / PROMPT_TEXT_FILENAME - existing = read_prompt_text(prompt_path) if ( - prompt_path.exists() and not args.force) else {} - if plan is None: + existing = read_prompt_text(prompt_path) if ( + prompt_path.exists() and not args.force) else {} plan = _decide_transcription( wav_files, existing, prompt_path.exists(), args.force, lambda question, default: ask_bool(question, default)) + else: + existing = plan.get("existing") or {} if plan["mode"] == "keep": print(f"[INFO] Kept existing {prompt_path}; all voices were " @@ -832,9 +1070,7 @@ def _offer_config_model_id_sync(model_id: str, f"in converter/config.py to '{model_id}' so " "audiobook.py uses this model", True) if accepted: - if update_config_model_ids(model_id, model_id): - print(f"[OK] Updated the model ids in {CONFIG_PATH}") - else: + if not update_config_model_ids(model_id, model_id): print(f"[WARNING] Could not update {CONFIG_PATH}; edit " "AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID by hand so " "audiobook.py uses this model") @@ -843,14 +1079,6 @@ def _offer_config_model_id_sync(model_id: str, f"still request model '{config.AUDIOCPP_MODEL_ID}'") -def _print_multi_model_model_id_note(entry_ids: List[str]) -> None: - """Tell the user how to select one entry per run for a multi-model server.""" - print("[INFO] Several model entries were configured. audiobook.py uses one " - "entry per run: pass --model <id> when converting, or set " - "AUDIOCPP_MODEL_ID in converter/config.py to one of: " - f"{', '.join(entry_ids)}") - - def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]], catalog_by_family: Dict[str, dict], task_picker: Callable[[str], str], @@ -890,14 +1118,18 @@ def _build_entries(family_keys: List[str], chosen: Dict[str, List[dict]], design_entry_ids, include_clone) -def _write_and_advise(wav_dir: Optional[Path], output_path: Path, - model_entries: List[dict], entry_ids: List[str], - install_guidance: List[Tuple[str, str]], - design_entry_ids: List[str], family_keys: List[str], - catalog_by_family: Dict[str, dict], host: str, port: int, - backend: str, lazy_load: bool, +def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path], + output_path: Path, model_entries: List[dict], + install_guidance: List[Tuple[str, str]], host: str, + port: int, backend: str, lazy_load: bool, transcripts: Dict[str, str], write_prompt: bool) -> None: - """Console phase shared by both UI modes: write files and print guidance.""" + """Console phase shared by both UI modes: write files, print summary. + + After a successful run the console output is the path of the written + server.json. The model install commands (and optional automatic + download) are handled separately by _install_models, called by both + UI modes once the user has decided whether to download. + """ voice_dir: Optional[str] = None if transcripts: if write_prompt: @@ -910,32 +1142,74 @@ def _write_and_advise(wav_dir: Optional[Path], output_path: Path, host=host, port=port, backend=backend, lazy_load=lazy_load, model_entries=model_entries, voice_dir=voice_dir) - print("\nGenerated server.json:") - print(json.dumps(server_config, indent=2, ensure_ascii=False)) - with output_path.open("w", encoding="utf-8") as handle: json.dump(server_config, handle, indent=2, ensure_ascii=False) handle.write("\n") - print(f"\n[OK] Wrote {output_path} with {len(model_entries)} model " - f"entry/entries" + (f" and voice_dir '{voice_dir}'" if voice_dir else "")) - for display_name, install_id in install_guidance: - print(f"[INFO] Install {display_name} from the audio.cpp checkout: " - f"python3 tools/model_manager_v2.py install {install_id}") - if len(model_entries) > 1: - print("[INFO] Models load lazily and stay in memory until the server " - "exits; restart the server (or POST /v1/tasks/unload_models) " - "before switching to a large model to free VRAM.") - for family in family_keys: - if catalog_by_family[family]["clone_capable"]: - print(f"[INFO] {catalog_by_family[family]['display_name']} supports " - "voice cloning: run audiobook.py with --backend audiocpp " - "--voice <preset name>") - for design_id in design_entry_ids: - print(f"[INFO] Voice design entry '{design_id}' hosted with task " - "'vdes': convert with python audiobook.py --backend audiocpp " - f"--model {design_id} " - '--instructions "A warm adult female narrator"') + count = len(model_entries) + print(f"Wrote {output_path.resolve()} with {count} " + f"{'entry' if count == 1 else 'entries'}.") + + +def _install_models(audiocpp_dir: Path, + install_guidance: List[Tuple[str, str]], + download: bool) -> None: + """Print and optionally run the model install commands. + + One ``python <manager> install <id>`` command per hosted model (de-duped + by install id). When DOWNLOAD is True each command is run in the audio.cpp + checkout via ``subprocess.run`` so the models are downloaded automatically; + a failing install is reported as a warning and does not abort the remaining + downloads. When DOWNLOAD is False (or the model manager is missing) the + commands are only printed, copy-pasteable as before. + """ + manager = audiocpp_dir / "tools" / "model_manager_v2.py" + seen: Set[str] = set() + install_ids: List[str] = [] + for _, install_id in install_guidance: + if install_id not in seen: + seen.add(install_id) + install_ids.append(install_id) + + if download and not manager.is_file(): + print(f"[WARNING] {manager} not found; printing the install commands " + "instead of running them") + download = False + + for install_id in install_ids: + command = f"python {manager} install {install_id}" + if not download: + print(command) + continue + print(f"[INFO] Downloading {install_id}...") + try: + result = subprocess.run( + [sys.executable, str(manager), "install", install_id], + cwd=str(audiocpp_dir)) + except OSError as exc: + print(f"[WARNING] Could not run {command}: {exc}") + continue + if result.returncode != 0: + print(f"[WARNING] install {install_id} exited with code " + f"{result.returncode}; the model may need to be downloaded " + "by hand") + + +def _decide_download(audiocpp_dir: Path, + confirm: Callable[[str, bool], bool]) -> bool: + """Ask whether to download the selected models now. + + CONFIRM asks the yes/no question (ask_bool for the line prompts, a TUI + confirm for the wizard). When the audio.cpp model manager is missing the + prompt is skipped and False is returned, so the install commands are only + printed rather than offered to run. + """ + manager = audiocpp_dir / "tools" / "model_manager_v2.py" + if not manager.is_file(): + return False + return confirm( + "Automatically download the selected models with model_manager_v2.py " + "now?", False) def _build_tree_families(catalog: List[dict]) -> List[dict]: @@ -950,16 +1224,11 @@ def _build_tree_families(catalog: List[dict]) -> List[dict]: name = entry["display_name"] if name != entry["family"]: name = f"{name} ({entry['family']})" - if entry["tested"]: - name = f"{name} [tested]" options = [] for opt in package_dir_options(entry): - label = opt["install_id"] - if opt["design"]: - label = f"{label} (voice design)" options.append({ "key": opt["target_directory"], - "label": label, + "label": opt["install_id"], "recommended": opt["recommended"], }) families.append({ @@ -972,194 +1241,348 @@ def _build_tree_families(catalog: List[dict]) -> List[dict]: def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser ) -> Optional[dict]: - """Run every TUI screen; return the collected settings, or None to abort.""" + """Run every TUI screen; return the collected settings, or None to abort. + + The wizard is a step state machine; each screen group is one step, and + Esc anywhere but the first step falls back to the previous group (the + widget returns the _GO_BACK sentinel, or a callback raises _GoBack). On + the first screen (the audio.cpp checkout browser) Esc aborts the whole + wizard as before. + """ tui = _load_tui() - # 1. audio.cpp checkout (flag, detected, or browsed). - audiocpp_dir = args.audiocpp_dir - if audiocpp_dir is None: - audiocpp_dir = detect_audiocpp_dir() - if audiocpp_dir is None: - audiocpp_dir = tui.browse_directory( - stdscr, "Locate your audio.cpp checkout", - validate=lambda p: None if (p / "model_specs").is_dir() - else "No model_specs/ directory here", - start=Path.cwd()) - audiocpp_dir = Path(audiocpp_dir).resolve() - if not audiocpp_dir.is_dir(): - raise _TuiError(f"audio.cpp checkout not found: {audiocpp_dir}") - try: - catalog = load_model_catalog(audiocpp_dir) - except NotADirectoryError as exc: - raise _TuiError(str(exc)) - if not catalog: - raise _TuiError(f"No TTS model families found in " - f"{audiocpp_dir}/model_specs; check the checkout is " - "up to date") - catalog_by_family = {entry["family"]: entry for entry in catalog} + def ask_confirm(question: str, default: bool) -> bool: + result = tui.confirm(stdscr, question, default=default, + cancel_value=_GO_BACK) + if result is _GO_BACK: + raise _GoBack() + return result - # 2. Output path + overwrite confirmation. - output_path = args.output if args.output is not None \ - else audiocpp_dir / "server.json" - if not args.force and output_path.exists() \ - and not tui.confirm(stdscr, - f"{output_path} already exists. Overwrite?", - default=True): - if args.output is None: - output_path = Path.cwd() / "server.json" - if output_path.exists() and not tui.confirm( - stdscr, f"{output_path} already exists. Overwrite?", - default=True): - return None - else: - return None + step = 0 + while True: + if step == 0: + # Checkout browser + the output path/overwrite confirmation. The + # browser asks for the checkout root and finds model_specs/ inside + # it (picking the model_specs directory itself works too — its + # parent is used). A highlighted subdirectory named "audio.cpp" + # that already contains model_specs/ is auto-accepted on + # Enter/Right, skipping the "[ Use this directory ]" step. + # Pressing Esc on an overwrite confirmation returns here instead + # of aborting: the browser then restarts inside the previously + # accepted checkout with auto-accept disabled, so a wrong guess + # can be corrected. An explicit --audiocpp-dir flag has no + # browser to return to, so Esc still aborts there. Esc on the + # browser itself is the first step, so it aborts the wizard. + auto_accept = True + browser_start: Path = Path.cwd() + force_browse = False + while True: + audiocpp_dir = args.audiocpp_dir + if audiocpp_dir is None: + audiocpp_dir = detect_audiocpp_dir() + if force_browse: + audiocpp_dir = None + if audiocpp_dir is None: + audiocpp_dir = tui.browse_directory( + stdscr, "Select your audio.cpp directory", + validate=lambda p: None if _resolve_audiocpp_root(p) + else "No model_specs/ directory here", + info=_audiocpp_root_status, + preview=_audiocpp_root_preview, + help_lines=["The root folder of your audio.cpp " + "checkout;", + "it is the one that contains " + "model_specs/"], + start=browser_start, + auto_select=_checkout_auto_select if auto_accept + else None) + audiocpp_dir = Path(audiocpp_dir).resolve() + if not audiocpp_dir.is_dir(): + raise _TuiError(f"audio.cpp checkout not found: " + f"{audiocpp_dir}") + root = _resolve_audiocpp_root(audiocpp_dir) + if root is None: + raise _TuiError( + f"{audiocpp_dir} has no model_specs/ directory; " + "select the root of your audio.cpp checkout") + audiocpp_dir = root + try: + catalog = load_model_catalog(audiocpp_dir) + except NotADirectoryError as exc: + raise _TuiError(str(exc)) + if not catalog: + raise _TuiError(f"No TTS model families found in " + f"{audiocpp_dir}/model_specs; check the " + "checkout is up to date") + catalog_by_family = {entry["family"]: entry + for entry in catalog} + + output_path = args.output if args.output is not None \ + else audiocpp_dir / "server.json" + esc_back = args.audiocpp_dir is None + went_back = False + if not args.force and output_path.exists(): + decision = tui.confirm( + stdscr, f"{output_path} already exists. Overwrite?", + default=True, + cancel_value=_GO_BACK if esc_back else None) + if decision is _GO_BACK: + went_back = True + elif decision is False: + if args.output is None: + output_path = Path.cwd() / "server.json" + if output_path.exists(): + decision = tui.confirm( + stdscr, + f"{output_path} already exists. " + "Overwrite?", + default=True, + cancel_value=_GO_BACK if esc_back else None) + if decision is _GO_BACK: + went_back = True + elif decision is False: + return None + else: + return None + if went_back: + auto_accept = False + browser_start = audiocpp_dir + force_browse = True + continue + break + detected_backend = detect_backend(audiocpp_dir) + step = 1 + continue - # 3. Families and packages (flag or tree). - chosen: Dict[str, List[dict]] = {} - if args.families is not None: - requested = [f.strip() for f in args.families.split(",") if f.strip()] - unknown = [f for f in requested if f not in catalog_by_family] - if unknown: - raise _TuiError( - f"Unknown family in --families: {', '.join(unknown)}. " - f"Available: {', '.join(catalog_by_family)}") - family_keys: List[str] = [] - for family in requested: - if family not in family_keys: - family_keys.append(family) - chosen[family] = [opt for opt in package_dir_options( - catalog_by_family[family]) if opt["recommended"]] - else: - tree_families = _build_tree_families(catalog) - picked = tui.checkbox_tree( - stdscr, "Select TTS model families to host", - tree_families, expand_all=args.all_packages) - family_keys = [] - for family_index, option_key in picked: - family = catalog[family_index]["family"] - if family not in chosen: - chosen[family] = [] - family_keys.append(family) - chosen[family].append(option_key) - for family in list(chosen): - keyed = {opt["target_directory"]: opt - for opt in package_dir_options(catalog_by_family[family])} - chosen[family] = [keyed[key] for key in chosen[family]] - - # 4. Design task menus and duplicate-id renames. - def task_picker(install_id: str) -> str: - return tui.menu( - stdscr, f"How should the '{install_id}' package be hosted?", - [ - ("design (vdes) - describe the voice with --instructions", - TASK_VDES), - ("tts - normal synthesis", TASK_TTS), - ], default_index=0) - - def id_picker(display_name: str, install_id: str, default: str) -> str: - return tui.line_edit( - stdscr, - f"Server model id for {display_name} package '{install_id}'", - default) + if step == 1: + # Families and packages (flag or tree). Esc returns to the + # checkout browser (step 0). + chosen: Dict[str, List[dict]] = {} + if args.families is not None: + requested = [f.strip() for f in args.families.split(",") + if f.strip()] + unknown = [f for f in requested if f not in catalog_by_family] + if unknown: + raise _TuiError( + f"Unknown family in --families: {', '.join(unknown)}. " + f"Available: {', '.join(catalog_by_family)}") + family_keys: List[str] = [] + for family in requested: + if family not in family_keys: + family_keys.append(family) + chosen[family] = [opt for opt in package_dir_options( + catalog_by_family[family]) if opt["recommended"]] + else: + tree_families = _build_tree_families(catalog) + picked = tui.checkbox_tree( + stdscr, "Select TTS model families to host", + tree_families, expand_all=args.all_packages, + back_value=_GO_BACK) + if picked is _GO_BACK: + step = 0 + continue + family_keys = [] + for family_index, option_key in picked: + family = catalog[family_index]["family"] + if family not in chosen: + chosen[family] = [] + family_keys.append(family) + chosen[family].append(option_key) + for family in list(chosen): + keyed = {opt["target_directory"]: opt + for opt in package_dir_options( + catalog_by_family[family])} + chosen[family] = [keyed[key] for key in chosen[family]] + step = 2 + continue - model_entries, entry_ids, install_guidance, design_entry_ids, include_clone = \ - _build_entries(family_keys, chosen, catalog_by_family, - task_picker, id_picker) + if step == 2: + # Design task menus and duplicate-id renames. Esc anywhere here + # falls back to the families tree (step 1). + def task_picker(install_id: str) -> str: + result = tui.menu( + stdscr, + f"How should the '{install_id}' package be hosted?", + [ + ("design (vdes) - describe the voice with " + "--instructions", TASK_VDES), + ("tts - normal synthesis", TASK_TTS), + ], default_index=0, back_value=_GO_BACK) + if result is _GO_BACK: + raise _GoBack() + return result + + def id_picker(display_name: str, install_id: str, + default: str) -> str: + result = tui.line_edit( + stdscr, + f"Server model id for {display_name} package " + f"'{install_id}'", default, back_value=_GO_BACK) + if result is _GO_BACK: + raise _GoBack() + return result + + try: + model_entries, entry_ids, install_guidance, \ + design_entry_ids, include_clone = _build_entries( + family_keys, chosen, catalog_by_family, + task_picker, id_picker) + except _GoBack: + step = 1 + continue + step = 3 + continue - # 5. Server settings. - host = args.host if args.host else tui.line_edit(stdscr, "Bind host", - DEFAULT_HOST) - if args.port is not None: - port = args.port - else: - port_text = tui.line_edit( - stdscr, "Port", str(config_port()), - validate=lambda s: None if (s.isdigit() and 1 <= int(s) <= 65535) - else "Enter a port number between 1 and 65535") - port = int(port_text) - sync_port: Optional[bool] = None - if port != config_port(): - sync_port = tui.confirm( - stdscr, f"Update AUDIOCPP_API_URL in converter/config.py to port " - f"{port} so audiobook.py talks to this server", default=True) - backend = args.backend if args.backend else tui.menu( - stdscr, "Which inference backend was audiocpp_server built for?", - [ - ("cuda - NVIDIA GPUs (fastest)", "cuda"), - ("vulkan - cross-vendor GPU", "vulkan"), - ("hip - AMD GPUs", "hip"), - ("cpu - no GPU required", "cpu"), - ], default_index=0) - default_lazy = len(model_entries) > 1 - lazy_load = args.lazy_load or tui.confirm( - stdscr, "Load models lazily (on first use instead of at startup)", - default=default_lazy) - - # 6. Wav directory (flag, browsed when cloning, else skipped). - if args.input_dir is not None: - wav_dir = args.input_dir - elif include_clone: - wav_dir = tui.browse_directory( - stdscr, "Directory with .wav voice cloning files", - start=Path.cwd()) - else: - wav_dir = None + if step == 3: + # Server settings (host, port, port-sync, backend, lazy). Esc on + # any of them falls back to the previous group (step 2). + if args.host: + host = args.host + else: + host = tui.line_edit( + stdscr, "Bind host", DEFAULT_HOST, + help_lines=["The IP address audiocpp will be hosted on", + "127.0.0.1 (this machine) is probably " + "correct"], back_value=_GO_BACK) + if host is _GO_BACK: + step = 2 + continue + if args.port is not None: + port = args.port + else: + port_text = tui.line_edit( + stdscr, "Port", str(config_port()), + validate=lambda s: None if (s.isdigit() + and 1 <= int(s) <= 65535) + else "Enter a port number between 1 and 65535", + help_lines=["The port audiocpp will be hosted on"], + back_value=_GO_BACK) + if port_text is _GO_BACK: + step = 2 + continue + port = int(port_text) + sync_port: Optional[bool] = None + if port != config_port(): + sync_port = tui.confirm( + stdscr, f"Update AUDIOCPP_API_URL in converter/config.py " + f"to port {port} so audiobook.py talks to this server", + default=True, cancel_value=_GO_BACK) + if sync_port is _GO_BACK: + step = 2 + continue + if args.backend: + backend = args.backend + else: + backend_options, backend_default = \ + _backend_options(detected_backend) + backend = tui.menu( + stdscr, "Which inference backend was audiocpp_server " + "built for?", backend_options, + default_index=backend_default, back_value=_GO_BACK) + if backend is _GO_BACK: + step = 2 + continue + default_lazy = len(model_entries) > 1 + if args.lazy_load: + lazy_load = True + else: + lazy_load = tui.confirm( + stdscr, "Load models lazily (on first use instead of at " + "startup)", default=default_lazy, cancel_value=_GO_BACK) + if lazy_load is _GO_BACK: + step = 2 + continue + step = 4 + continue - # 7. Transcription plan (questions only; transcription runs after). - plan: Optional[dict] = None - if include_clone and wav_dir is not None: - wav_files = find_wav_files(wav_dir) - if wav_files: - prompt_path = wav_dir / PROMPT_TEXT_FILENAME - existing = read_prompt_text(prompt_path) if ( - prompt_path.exists() and not args.force) else {} - plan = _decide_transcription( - wav_files, existing, prompt_path.exists(), args.force, - lambda question, default: tui.confirm(stdscr, question, default)) - - # 8. Single-model id sync decision. - sync_model_ids: Optional[bool] = None - if len(entry_ids) == 1 and not ( - config.AUDIOCPP_MODEL_ID == entry_ids[0] - and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]): - sync_model_ids = tui.confirm( - stdscr, "Update AUDIOCPP_MODEL_ID and AUDIOCPP_CLONE_MODEL_ID in " - f"converter/config.py to '{entry_ids[0]}' so audiobook.py uses " - "this model", default=True) - - # 9. Summary and final confirmation. - summary_lines = [ - f"Output: {output_path}", - f"Server: {host}:{port} ({backend}, lazy_load={'on' if lazy_load else 'off'})", - f"Models: {', '.join(entry_ids)}", - ] - if wav_dir is not None: - summary_lines.append(f"Voices: {wav_dir}") - if not tui.confirm(stdscr, "Generate server.json?", default=True, - body=summary_lines): - return None + if step == 4: + # Wav directory (flag, browsed when cloning, else skipped). Esc + # falls back to the server settings (step 3). + if args.input_dir is not None: + wav_dir = args.input_dir + elif include_clone: + wav_start = detect_wav_dir(audiocpp_dir, TTS_ROOT) + wav_dir = tui.browse_directory( + stdscr, "Select the directory with your .wav voices", + info=_wav_dir_info, preview=_wav_dir_preview, + start=wav_start if wav_start is not None else Path.cwd(), + back_value=_GO_BACK) + if wav_dir is _GO_BACK: + step = 3 + continue + else: + wav_dir = None + step = 5 + continue - return { - "audiocpp_dir": audiocpp_dir, - "catalog": catalog, - "catalog_by_family": catalog_by_family, - "output_path": output_path, - "family_keys": family_keys, - "chosen": chosen, - "model_entries": model_entries, - "entry_ids": entry_ids, - "install_guidance": install_guidance, - "design_entry_ids": design_entry_ids, - "include_clone": include_clone, - "host": host, - "port": port, - "backend": backend, - "lazy_load": lazy_load, - "sync_port": sync_port, - "sync_model_ids": sync_model_ids, - "wav_dir": wav_dir, - "plan": plan, - } + if step == 5: + # Transcription plan (questions only; transcription runs after). + # Esc falls back to the wav browser (step 4). + plan: Optional[dict] = None + if include_clone and wav_dir is not None: + wav_files = find_wav_files(wav_dir) + if wav_files: + prompt_path = wav_dir / PROMPT_TEXT_FILENAME + existing = read_prompt_text(prompt_path) if ( + prompt_path.exists() and not args.force) else {} + try: + plan = _decide_transcription( + wav_files, existing, prompt_path.exists(), + args.force, ask_confirm) + except _GoBack: + step = 4 + continue + step = 6 + continue + + if step == 6: + # Single-model id sync decision. Esc falls back to the + # transcription plan (step 5). + sync_model_ids: Optional[bool] = None + if len(entry_ids) == 1 and not ( + config.AUDIOCPP_MODEL_ID == entry_ids[0] + and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]): + sync_model_ids = tui.confirm( + stdscr, "Update AUDIOCPP_MODEL_ID and " + "AUDIOCPP_CLONE_MODEL_ID in converter/config.py to " + f"'{entry_ids[0]}' so audiobook.py uses this model", + default=True, cancel_value=_GO_BACK) + if sync_model_ids is _GO_BACK: + step = 5 + continue + step = 8 + continue + + if step == 8: + # Automatic model download (or print the install commands). Esc + # falls back to the model-id sync (step 6). + try: + download = _decide_download(audiocpp_dir, ask_confirm) + except _GoBack: + step = 6 + continue + return { + "audiocpp_dir": audiocpp_dir, + "catalog": catalog, + "catalog_by_family": catalog_by_family, + "output_path": output_path, + "family_keys": family_keys, + "chosen": chosen, + "model_entries": model_entries, + "entry_ids": entry_ids, + "install_guidance": install_guidance, + "design_entry_ids": design_entry_ids, + "include_clone": include_clone, + "host": host, + "port": port, + "backend": backend, + "lazy_load": lazy_load, + "sync_port": sync_port, + "sync_model_ids": sync_model_ids, + "wav_dir": wav_dir, + "plan": plan, + "download": download, + } def _run_tui(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: @@ -1174,6 +1597,10 @@ def _run_tui(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: except tui.WizardCancelled: print("\n[INFO] Cancelled; nothing was written") return 1 + try: + curses.curs_set(1) # restore the text cursor hidden by the TUI + except curses.error: + pass if settings is None: print("[INFO] Aborted; existing server.json kept") return 1 @@ -1196,18 +1623,17 @@ def _run_tui(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: transcripts, write_prompt = {}, False _write_and_advise( - settings["wav_dir"], settings["output_path"], settings["model_entries"], - settings["entry_ids"], settings["install_guidance"], - settings["design_entry_ids"], settings["family_keys"], - settings["catalog_by_family"], settings["host"], settings["port"], - settings["backend"], settings["lazy_load"], transcripts, write_prompt) + settings["audiocpp_dir"], settings["wav_dir"], settings["output_path"], + settings["model_entries"], settings["install_guidance"], + settings["host"], settings["port"], settings["backend"], + settings["lazy_load"], transcripts, write_prompt) if len(settings["entry_ids"]) == 1: _offer_config_model_id_sync(settings["entry_ids"][0], settings["sync_model_ids"]) - elif len(settings["entry_ids"]) > 1: - _print_multi_model_model_id_note(settings["entry_ids"]) print_empty_transcript_warning(transcripts) + _install_models(settings["audiocpp_dir"], settings["install_guidance"], + settings["download"]) return 0 @@ -1250,7 +1676,8 @@ def main() -> int: "AUDIOCPP_API_URL from converter/config.py)") parser.add_argument("--backend", choices=BACKENDS, default=None, help="Inference backend audiocpp_server was built " - "for (default: cuda)") + "for (default: auto-detected from the checkout's " + "build/ directory, else cuda)") parser.add_argument("--lazy-load", action="store_true", help="Load models on first use instead of at startup " "(default: on when more than one model is hosted)") @@ -1279,9 +1706,16 @@ def main() -> int: # ---- Line-prompt flow (original behaviour). --------------------------- - # Resolve the wav directory (flag, else prompt). + # Resolve the wav directory (flag, else prompt). The prompt default is + # the unique directory that directly contains .wav files across the + # audio.cpp checkout (best-effort detected here) and the + # tts-audiobook-generator root, so the user usually just presses Enter. if args.input_dir is None: - answer = ask("Directory with .wav reference files", "") + tentative_checkout = args.audiocpp_dir or detect_audiocpp_dir() + wav_start = detect_wav_dir(tentative_checkout, TTS_ROOT) \ + if tentative_checkout is not None else None + default = str(wav_start) if wav_start is not None else "" + answer = ask("Directory with .wav reference files", default) args.input_dir = resolve_wav_dir_arg(answer) if answer else None if args.input_dir is None: parser.error("--wavs is required: a directory containing the .wav " @@ -1312,6 +1746,11 @@ def main() -> int: audiocpp_dir = audiocpp_dir.resolve() if not audiocpp_dir.is_dir(): parser.error(f"audio.cpp checkout not found: {audiocpp_dir}") + root = _resolve_audiocpp_root(audiocpp_dir) + if root is None: + parser.error(f"{audiocpp_dir} has no model_specs/ directory; point " + "--audiocpp-dir at the root of an audio.cpp checkout") + audiocpp_dir = root try: catalog = load_model_catalog(audiocpp_dir) except NotADirectoryError as exc: @@ -1377,20 +1816,24 @@ def main() -> int: # single-entry server loads at startup, while a multi-entry server avoids # loading every model until it is actually used. default_lazy = len(model_entries) > 1 - host, port, backend, lazy_load = _ask_host_port_backend_lazy(args, default_lazy) + detected_backend = detect_backend(audiocpp_dir) + host, port, backend, lazy_load = _ask_host_port_backend_lazy( + args, default_lazy, detected_backend) transcripts, write_prompt = _transcribe(args, include_clone) _write_and_advise( - args.input_dir, output_path, model_entries, entry_ids, - install_guidance, design_entry_ids, family_keys, catalog_by_family, - host, port, backend, lazy_load, transcripts, write_prompt) + audiocpp_dir, args.input_dir, output_path, model_entries, + install_guidance, host, port, backend, lazy_load, transcripts, + write_prompt) if len(entry_ids) == 1: _offer_config_model_id_sync(entry_ids[0]) - elif len(entry_ids) > 1: - _print_multi_model_model_id_note(entry_ids) print_empty_transcript_warning(transcripts) + + download = _decide_download( + audiocpp_dir, lambda question, default: ask_bool(question, default)) + _install_models(audiocpp_dir, install_guidance, download) return 0 |
