From bbc568477b28241236c998071ad9c355f0d0e874 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 00:21:51 -0400 Subject: refactor(audiocpp): pin checkout to app/audio.cpp, report install results --- app/backends/audiocpp.py | 295 ++++++++++++++++++----------------------------- 1 file changed, 114 insertions(+), 181 deletions(-) (limited to 'app/backends') diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index f6121ea..4bcf8fa 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -17,7 +17,7 @@ catalog works without further changes. Usage: python app/backends/audiocpp.py [--wavs WAV_DIR] [--output PATH] - [--audiocpp-dir PATH] [--clone] [--families FAM1,FAM2] + [--clone] [--families FAM1,FAM2] [--all-packages] [--host HOST] [--port PORT] [--build-backend {cuda,vulkan,hip,cpu}] [--backend {cuda,vulkan,hip,cpu}] [--whisper-model NAME] [--force] @@ -36,6 +36,8 @@ longer selected. """ import argparse +import contextlib +import io import json import os import re @@ -68,9 +70,9 @@ from backends.common import ( VOICES_DIR, detect_wav_dir, find_wav_files, - normalize_dir_arg, read_prompt_text, resolve_wav_dir_arg, + url_with_port, write_prompt_text, ) from backends.common import ( @@ -91,7 +93,7 @@ BACKENDS = ("cuda", "vulkan", "hip", "cpu") TASK_TTS = "tts" TASK_VDES = "vdes" -# audio.cpp is cloned into a sibling directory of the audiobook generator. +# audio.cpp is cloned into the app directory of the audiobook generator. AUDIOCPP_DIR_NAME = "audio.cpp" AUDIOCPP_GIT_URL = "https://github.com/0xShug0/audio.cpp" @@ -144,21 +146,6 @@ def _interactive() -> bool: return False -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 - - # Backend display order, with short descriptions. The backend name is padded # so the descriptions' dashes line up in the menu. _BACKEND_DESCRIPTIONS = ( @@ -199,36 +186,26 @@ def config_port() -> int: return FALLBACK_PORT -def _url_with_port(url: str, port: int) -> str: - parts = urllib.parse.urlsplit(url) - host = parts.hostname or "127.0.0.1" - return urllib.parse.urlunsplit( - (parts.scheme or "http", f"{host}:{port}", parts.path, "", "")) - - def update_config_api_url_port(port: int, config_path: Optional[Path] = None) -> bool: """Rewrite the port inside AUDIOCPP_API_URL in app/converter/config.py. - Only the quoted URL literal is replaced; surrounding lines and the - trailing comment are preserved. Returns True when the file was changed. + Reads the configured URL from the file (not from the imported module, + which a long hub session can leave behind), swaps its port for PORT, + and writes it back through ``common.update_config_value`` so the + imported module mirrors the change immediately. Returns True when the + file now holds the new URL. """ path = Path(config_path) if config_path is not None else CONFIG_PATH try: text = path.read_text(encoding="utf-8") except OSError: return False - match = re.search(r'(?m)^(\s*AUDIOCPP_API_URL\s*=\s*")([^"]*)(")', text) + match = re.search(r'(?m)^\s*AUDIOCPP_API_URL\s*=\s*"([^"]*)"', text) if not match: return False - new_url = _url_with_port(match.group(2), port) - if new_url == match.group(2): - return False - text = text[:match.start(2)] + new_url + text[match.end(2):] - try: - path.write_text(text, encoding="utf-8") - except OSError: - return False - return True + return common.update_config_value("AUDIOCPP_API_URL", + url_with_port(match.group(1), port), + config_path=path) def update_server_config_port(port: int) -> bool: @@ -269,57 +246,18 @@ def update_config_model_ids(model_id: str, config_path: Optional[Path] = None) -> bool: """Rewrite AUDIOCPP_MODEL_ID (and AUDIOCPP_CLONE_MODEL_ID when given). - Only the quoted id literals are replaced; surrounding lines and - comments are preserved. Returns True when the file was changed. + Goes through ``common.update_config_value`` so the imported config + module mirrors the change immediately. Returns True when every named + key now holds its value in the file. """ path = Path(config_path) if config_path is not None else CONFIG_PATH - try: - text = path.read_text(encoding="utf-8") - except OSError: - return False - updates: List[Tuple[str, str]] = [("AUDIOCPP_MODEL_ID", model_id)] + ok = common.update_config_value("AUDIOCPP_MODEL_ID", model_id, + config_path=path) if clone_model_id is not None: - updates.append(("AUDIOCPP_CLONE_MODEL_ID", clone_model_id)) - changed = False - for name, value in updates: - match = re.search(r'(?m)^(\s*' + name + r'\s*=\s*")([^"]*)(")', text) - if match and match.group(2) != value: - text = text[:match.start(2)] + value + text[match.end(2):] - changed = True - if not changed: - return False - try: - path.write_text(text, encoding="utf-8") - except OSError: - return False - return True - - -def detect_audiocpp_dir() -> Optional[Path]: - """Best-effort location of a local audio.cpp checkout with model_specs. - - Checks the AUDIOCPP_DIR environment variable, then ``app/audio.cpp`` in - the tts-audiobook-generator root, then an ``audio.cpp`` directory in or - above the current working directory. Returns the path only when it - contains a ``model_specs`` directory. - """ - candidates: List[Path] = [] - env_dir = os.environ.get("AUDIOCPP_DIR") - if env_dir: - candidates.append(Path(os.path.expanduser(env_dir))) - candidates.append(APP_DIR / AUDIOCPP_DIR_NAME) - cwd = Path.cwd() - candidates.append(cwd / "audio.cpp") - candidates.append(cwd.parent / "audio.cpp") - candidates.append(cwd.parent.parent / "audio.cpp") - for candidate in candidates: - try: - resolved = candidate.resolve() - except OSError: - continue - if (resolved / "model_specs").is_dir(): - return resolved - return None + ok = common.update_config_value("AUDIOCPP_CLONE_MODEL_ID", + clone_model_id, + config_path=path) and ok + return ok # audio.cpp build directories are named ``--`` (e.g. @@ -398,8 +336,8 @@ def load_model_catalog(audiocpp_dir: Path) -> List[dict]: specs_dir = audiocpp_dir / "model_specs" if not specs_dir.is_dir(): raise NotADirectoryError( - f"{audiocpp_dir} has no model_specs/ directory; point " - "--audiocpp-dir at an audio.cpp checkout") + f"{audiocpp_dir} has no model_specs/ directory; re-run setup " + "to refresh the audio.cpp checkout") entries: List[dict] = [] for spec_path in sorted(specs_dir.glob("*.json")): try: @@ -604,21 +542,17 @@ def _decide_transcription(wav_files: list, existing: Dict[str, str], return {"mode": mode, "missing": missing, "existing": existing} -def _transcribe(args: argparse.Namespace, include_clone: bool, - plan: dict, cancel=None) -> Tuple[Dict[str, str], bool]: +def _transcribe(args: argparse.Namespace, plan: Optional[dict], + cancel=None) -> Tuple[Dict[str, str], bool]: """Transcribe the wav directory into a stem -> transcript mapping. 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). PLAN is always pre-collected — by the TUI (via _decide_transcription and its confirm callbacks) or by _flag_plan for a non-interactive run — so no - questions are asked here. CANCEL is checked between files. + questions are asked here; a None PLAN defaults to "transcribe everything". + CANCEL is checked between files. """ - if not include_clone: - print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family " - "selected, so voice presets are not used") - return {}, False - wav_files = find_wav_files(args.input_dir) if not wav_files: print(f"[WARNING] No .wav files found in {args.input_dir}; writing the " @@ -626,9 +560,10 @@ def _transcribe(args: argparse.Namespace, include_clone: bool, return {}, False prompt_path = args.input_dir / PROMPT_TEXT_FILENAME - existing = plan.get("existing") or {} if plan else {} + existing = dict((plan or {}).get("existing") or {}) + mode = plan["mode"] if plan else "all" - if plan["mode"] == "keep": + if mode == "keep": print(f"[INFO] Kept existing {prompt_path}; all voices were " "already transcribed, nothing new to transcribe") return existing, False @@ -778,7 +713,7 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path], def _install_models(audiocpp_dir: Path, install_guidance: List[Tuple[str, str]], - download: bool, emit=None, cancel=None) -> None: + download: bool, emit=None, cancel=None) -> int: """Print and optionally run the model install commands. One ``python install `` command per hosted model (de-duped @@ -792,6 +727,9 @@ def _install_models(audiocpp_dir: Path, to EMIT and — when the checkout's ``model_manager_v2.py`` supports it — runs with ``--progress --cancel-file`` so the view can show a real byte progress bar and cancel gracefully. CANCEL aborts a running download. + + Returns 0 when every command succeeded (or nothing needed running), + 130 when cancelled, 1 when any download failed. """ manager = audiocpp_dir / "tools" / "model_manager_v2.py" seen: Set[str] = set() @@ -808,6 +746,7 @@ def _install_models(audiocpp_dir: Path, "instead of running them") download = False + failed = False for install_id in install_ids: command = f"python {manager} install {install_id}" if not download: @@ -838,10 +777,14 @@ def _install_models(audiocpp_dir: Path, cancel_file.unlink() except OSError: pass + if rc == 130 or (cancel is not None and cancel.is_set()): + return 130 if rc != 0: + failed = True print(f"[WARNING] install {install_id} exited with code " f"{rc}; the model may need to be downloaded " "by hand") + return 1 if failed else 0 def _manager_supports_progress(manager: Path) -> bool: @@ -932,17 +875,8 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser return result def resolve_checkout(audiocpp_dir: Path) -> None: - """Validate AUDIOCPP_DIR and populate the wizard state ``s``.""" + """Validate the audio.cpp checkout and populate the wizard state ``s``.""" 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: @@ -1336,9 +1270,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser # checkout exists, clone it into ./app/audio.cpp (streaming inside the # TUI task view, not by dropping to the console) without asking, then # continue the same way. - audiocpp_dir = args.audiocpp_dir - if audiocpp_dir is None: - audiocpp_dir = find_local_checkout() + audiocpp_dir = find_local_checkout() if audiocpp_dir is None: target = APP_DIR / AUDIOCPP_DIR_NAME rc = taskview.run_steps(stdscr, "Clone audio.cpp", [ @@ -1438,7 +1370,7 @@ def _model_path_present(path: Path) -> bool: def _all_models_present(audiocpp_dir: Path, model_entries: List[dict]) -> bool: """True when every selected model entry's path already holds files on disk. - Paths resolve against AUDIOCPP_DIR (where model_manager_v2.py installs + Paths resolve against the checkout (where model_manager_v2.py installs them), honoring absolute paths. Used by the wizard to skip the "Automatically download the selected models" prompt when nothing is actually missing. An empty selection is treated as not-present. @@ -1571,17 +1503,18 @@ def model_install_hints(audiocpp_dir: Path, def install_models(audiocpp_dir: Path, guidance: List[Tuple[str, str]], - emit=None, cancel=None) -> None: + emit=None, cancel=None) -> int: """Download the (display name, install id) models via the helper script. Runs ``model_manager_v2.py install`` for each de-duped install id in the checkout, streaming to the console (or to EMIT, the in-TUI task view); a - failing install is reported as a warning and does not abort the rest. Used - by the hub's "Download Missing Models" action (see + failing install is reported as a warning and does not abort the rest. + Returns 0 when every download succeeded, 130 when cancelled, 1 when any + failed. Used by the hub's "Download Missing Models" action (see ``missing_model_install_guidance`` for the mapping). """ - _install_models(audiocpp_dir, guidance, download=True, - emit=emit, cancel=cancel) + return _install_models(audiocpp_dir, guidance, download=True, + emit=emit, cancel=cancel) def hand_install_guidance(audiocpp_dir: Path, @@ -1590,7 +1523,7 @@ def hand_install_guidance(audiocpp_dir: Path, Returns a multi-line message listing each missing model's id and the path its files must be placed in (``rel``, resolved against the - AUDIOCPP_DIR checkout). Used when the missing models cannot be mapped to + checkout). Used when the missing models cannot be mapped to a ``model_manager_v2.py install`` command, so the user still knows what to download and where to put it. """ @@ -1653,10 +1586,10 @@ def delete_model_files(server_json: Path, entries: List[dict]) -> int: def uninstall(*, emit=None, cancel=None) -> int: """Remove the audio.cpp backend entirely: stop its server, delete the checkout. - The checkout (``app/audio.cpp``, or wherever ``find_local_checkout`` - resolves it) holds the built binary, the downloaded models, and the - server.json, so removing the directory uninstalls the backend. A running - server this tool started is stopped first (best-effort). + The checkout (``app/audio.cpp``) holds the built binary, the downloaded + models, and the server.json, so removing the directory uninstalls the + backend. A running server this tool started is stopped first + (best-effort). EMIT is accepted for registry symmetry with the other backends but is unused here — this uninstall has no subprocess phase, and its prints are @@ -1666,7 +1599,11 @@ def uninstall(*, emit=None, cancel=None) -> int: completes and the uninstall never tears halfway. Returns the exit code (130 when cancelled before a remaining phase). """ - servers.stop("audiocpp") + # Only stop when a pid file exists: without one this tool never + # started the server, so the "not started by this tool" notice would + # be uninstall-time noise. + if servers.pid_for("audiocpp") is not None: + servers.stop("audiocpp") if common.cancel_requested(cancel): return 130 checkout = find_local_checkout() @@ -1680,29 +1617,17 @@ def uninstall(*, emit=None, cancel=None) -> int: def find_local_checkout() -> Optional[Path]: - """Best-effort location of an audio.cpp checkout with model_specs. + """Return the managed audio.cpp checkout at ``app/audio.cpp``. - Checks the AUDIOCPP_DIR environment variable, then ``app/audio.cpp`` - inside the tts-audiobook-generator root, then an ``audio.cpp`` directory - in or above the current working directory. Returns the path only when it - contains a ``model_specs`` directory. + Returns the path only when it contains a ``model_specs`` directory; + the checkout is installed there by the setup wizard and nowhere else. """ - candidates: List[Path] = [] - env_dir = os.environ.get("AUDIOCPP_DIR") - if env_dir: - candidates.append(Path(os.path.expanduser(env_dir))) - candidates.append(APP_DIR / AUDIOCPP_DIR_NAME) - cwd = Path.cwd() - candidates.append(cwd / AUDIOCPP_DIR_NAME) - candidates.append(cwd.parent / AUDIOCPP_DIR_NAME) - candidates.append(cwd.parent.parent / AUDIOCPP_DIR_NAME) - for candidate in candidates: - try: - resolved = candidate.resolve() - except OSError: - continue - if (resolved / "model_specs").is_dir(): - return resolved + try: + resolved = (APP_DIR / AUDIOCPP_DIR_NAME).resolve() + except OSError: + return None + if (resolved / "model_specs").is_dir(): + return resolved return None @@ -1886,6 +1811,8 @@ def apply_ggml_patches(audiocpp_dir: Path, *, emit=None, cancel=None) -> int: "--whitespace=nowarn", str(patch_path)] check_rc = common.run_console_subprocess( check_argv, emit=emit, cancel=cancel) + if check_rc == 130 or (cancel is not None and cancel.is_set()): + return 130 if check_rc != 0: print(f"[ERROR] {patch['file']}: no longer applies to " f"{patch['target']} (git apply --check exit {check_rc}). " @@ -1898,6 +1825,8 @@ def apply_ggml_patches(audiocpp_dir: Path, *, emit=None, cancel=None) -> int: "--whitespace=nowarn", str(patch_path)] rc = common.run_console_subprocess( apply_argv, emit=emit, cancel=cancel) + if rc == 130 or (cancel is not None and cancel.is_set()): + return 130 if rc != 0: print(f"[ERROR] {patch['file']}: git apply failed (exit {rc})") return rc @@ -1930,20 +1859,19 @@ def build_audiocpp(audiocpp_dir: Path, backend: str, *, if emit is not None: common.record_post_tui_notice(message) return 1 - patch_rc = apply_ggml_patches(audiocpp_dir, emit=emit, cancel=cancel) - if patch_rc != 0: - message = ("[ERROR] ggml build patches could not be applied; " - "aborting audiocpp_server build. See the messages above " - "and re-evaluate app/backends/patches/.") - print(message) - if emit is not None: - common.record_post_tui_notice(message) - return patch_rc argv = ["sh", str(script), "--backend", backend, "--target", "audiocpp_server", "--deployment-build"] command = f"cd {audiocpp_dir} && {shlex.join(argv)}" if emit is None: print(f"[INFO] Building audiocpp_server for {backend} ({command})...") + patch_rc = apply_ggml_patches(audiocpp_dir, cancel=cancel) + if patch_rc == 130 or (cancel is not None and cancel.is_set()): + return 130 + if patch_rc != 0: + print("[ERROR] ggml build patches could not be applied; " + "aborting audiocpp_server build. See the messages above " + "and re-evaluate app/backends/patches/.") + return patch_rc return common.run_console_subprocess(argv, cwd=audiocpp_dir) return _build_audiocpp_tui(emit, cancel, argv, command, audiocpp_dir) @@ -1952,12 +1880,13 @@ def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str, audiocpp_dir: Path) -> int: """Run the build on the TUI path: tee output to a log file. - Every emitted line is also written (and flushed) to + The ggml patch step runs first, inside the same log: every emitted + line (patch status, build output) is also written (and flushed) to ``app/logs/audiocpp_build_.log``. On failure a summary (the - copy-pastable COMMAND and the log path) is emitted into the TUI, written - to the log, and queued as a post-TUI console notice. A cancelled build - (CANCEL set) is not reported as a failure, but its partial output stays - in the log file. + copy-pastable COMMAND and the log path) is emitted into the TUI, + written to the log, and queued as a post-TUI console notice. A + cancelled build (CANCEL set) is not reported as a failure, but its + partial output stays in the log file. """ log_path = common.LOG_DIR / ( f"audiocpp_build_{datetime.now():%Y%m%d_%H%M%S}.log") @@ -1969,9 +1898,29 @@ def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str, log_handle.flush() emit(line) - tee(f"[INFO] Building audiocpp_server ({command})...") - rc = 0 + class _TeeWriter(io.TextIOBase): + """Route print() output from the patch step into the log too.""" + + def write(self, s: str) -> int: + for line in s.splitlines(): + if line: + tee(line) + return len(s) + try: + with contextlib.redirect_stdout(_TeeWriter()): + patch_rc = apply_ggml_patches(audiocpp_dir, emit=tee, + cancel=cancel) + if patch_rc == 130 or (cancel is not None and cancel.is_set()): + return 130 + if patch_rc != 0: + notice = ("[ERROR] ggml build patches could not be applied; " + "aborting audiocpp_server build. See the messages " + "above and re-evaluate app/backends/patches/.") + tee(notice) + common.record_post_tui_notice(notice) + return patch_rc + tee(f"[INFO] Building audiocpp_server ({command})...") rc = common.run_console_subprocess( argv, cwd=audiocpp_dir, emit=tee, cancel=cancel) if rc != 0 and (cancel is None or not cancel.is_set()): @@ -2048,7 +1997,7 @@ def _execute_lanes(settings: dict, args.input_dir = settings["wav_dir"] if settings["include_clone"] and args.input_dir is not None: transcripts, write_prompt = _transcribe( - args, True, plan=settings["plan"], cancel=cancel) + args, plan=settings["plan"], cancel=cancel) elif args.input_dir is not None: print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable " "family selected, so voice presets are not used") @@ -2302,10 +2251,8 @@ def _collect_from_flags(args: argparse.Namespace, the settings dict, or None when the user declined an overwrite (the default-location fallback then also exists). """ - # Checkout: --audiocpp-dir, else a local checkout, else --clone clones one. - audiocpp_dir = args.audiocpp_dir - if audiocpp_dir is None: - audiocpp_dir = find_local_checkout() + # Checkout: ./app/audio.cpp, else --clone clones one there. + audiocpp_dir = find_local_checkout() if audiocpp_dir is None and args.clone: target = APP_DIR / AUDIOCPP_DIR_NAME rc = common.git_clone(AUDIOCPP_GIT_URL, target) @@ -2322,17 +2269,8 @@ def _collect_from_flags(args: argparse.Namespace, audiocpp_dir = target if audiocpp_dir is None: parser.error( - "An audio.cpp checkout is required. Pass --audiocpp-dir PATH, " - "or --clone to clone app/audio.cpp, or run without flags for the " - "TUI wizard.") - audiocpp_dir = Path(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 + "An audio.cpp checkout is required. Pass --clone to clone " + "app/audio.cpp, or run without flags for the TUI wizard.") try: catalog = load_model_catalog(audiocpp_dir) except NotADirectoryError as exc: @@ -2465,11 +2403,6 @@ def build_parser() -> argparse.ArgumentParser: "server.json inside the audio.cpp checkout; an " "existing file is overwritten only with --force " "or a TUI confirm)") - parser.add_argument("--audiocpp-dir", type=normalize_dir_arg, default=None, - help="Path to a local audio.cpp checkout containing a " - "model_specs/ directory (default: detected from " - "AUDIOCPP_DIR or ./app/audio.cpp; in the TUI you can " - "clone one instead)") parser.add_argument("--clone", action="store_true", help="Non-interactive: clone audio.cpp into " "./app/audio.cpp when no checkout is found") -- cgit v1.2.3