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 ++++++++++++++---------------------- app/tests/test_backends_audiocpp.py | 87 ++++++++--- 2 files changed, 180 insertions(+), 202 deletions(-) (limited to 'app') 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") diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 3063e99..05b47bd 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock, patch from converter import config from backends import audiocpp as make_server +from backends import common from ui import tui FAKE_CONFIG = ( @@ -222,13 +223,14 @@ class ConfigPortTests(unittest.TestCase): make_server.FALLBACK_PORT) def test_url_with_port_replaces_port(self): + # audiocpp reuses the shared helper (backends.common.url_with_port). self.assertEqual( - make_server._url_with_port("http://127.0.0.1:8080", 9000), + make_server.url_with_port("http://127.0.0.1:8080", 9000), "http://127.0.0.1:9000") def test_url_without_port_adds_port(self): self.assertEqual( - make_server._url_with_port("http://localhost", 8080), + make_server.url_with_port("http://localhost", 8080), "http://localhost:8080") @@ -237,8 +239,11 @@ class UpdateConfigPortTests(unittest.TestCase): self._tmp = tempfile.TemporaryDirectory() self.config_path = Path(self._tmp.name) / "config.py" self.config_path.write_text(FAKE_CONFIG, encoding="utf-8") + # The shared helper also mirrors values onto converter.config. + self._saved_url = config.AUDIOCPP_API_URL def tearDown(self): + config.AUDIOCPP_API_URL = self._saved_url self._tmp.cleanup() def test_rewrites_port_preserving_comment(self): @@ -258,8 +263,9 @@ class UpdateConfigPortTests(unittest.TestCase): self.assertFalse(make_server.update_config_api_url_port( 8080, config_path=path)) - def test_returns_false_when_port_unchanged(self): - self.assertFalse(make_server.update_config_api_url_port( + def test_port_unchanged_is_a_success_noop(self): + # The file already holds the port: success, nothing rewritten. + self.assertTrue(make_server.update_config_api_url_port( 9999, config_path=self.config_path)) self.assertEqual(self.config_path.read_text(encoding="utf-8"), FAKE_CONFIG) @@ -275,8 +281,13 @@ class UpdateConfigModelIdsTests(unittest.TestCase): self.config_path = Path(self._tmp.name) / "config.py" self.config_path.write_text(FAKE_CONFIG_WITH_MODEL_IDS, encoding="utf-8") + # The shared helper also mirrors values onto converter.config. + self._saved_ids = (config.AUDIOCPP_MODEL_ID, + config.AUDIOCPP_CLONE_MODEL_ID) def tearDown(self): + (config.AUDIOCPP_MODEL_ID, + config.AUDIOCPP_CLONE_MODEL_ID) = self._saved_ids self._tmp.cleanup() def test_rewrites_both_ids_preserving_lines(self): @@ -297,10 +308,11 @@ class UpdateConfigModelIdsTests(unittest.TestCase): self.assertIn('AUDIOCPP_MODEL_ID = "voxcpm2"', text) self.assertIn('AUDIOCPP_CLONE_MODEL_ID = "qwen-clone"', text) - def test_returns_false_when_ids_unchanged(self): + def test_ids_unchanged_is_a_success_noop(self): + # Both ids already hold their values: success, nothing rewritten. changed = make_server.update_config_model_ids( "qwen", "qwen-clone", config_path=self.config_path) - self.assertFalse(changed) + self.assertTrue(changed) self.assertEqual(self.config_path.read_text(encoding="utf-8"), FAKE_CONFIG_WITH_MODEL_IDS) @@ -355,22 +367,43 @@ class ResolveWavDirArgTests(unittest.TestCase): class NormalizeDirArgTests(unittest.TestCase): - """Path normalization for the audio.cpp checkout argument.""" + """Path normalization for user-supplied directory arguments.""" def test_expands_tilde_and_resolves(self): - with patch.object(make_server.os.path, "expanduser", + with patch.object(common.os.path, "expanduser", return_value="/home/u/audio.cpp") as mock_expand: - result = make_server.normalize_dir_arg("~/audio.cpp") + result = common.normalize_dir_arg("~/audio.cpp") mock_expand.assert_called_once_with("~/audio.cpp") self.assertEqual(result, Path("/home/u/audio.cpp").resolve()) def test_strips_quotes_and_whitespace(self): - with patch.object(make_server.os.path, "expanduser", + with patch.object(common.os.path, "expanduser", side_effect=lambda s: s): - result = make_server.normalize_dir_arg(' "/tmp/foo" ') + result = common.normalize_dir_arg(' "/tmp/foo" ') self.assertEqual(result, Path("/tmp/foo").resolve()) +class FindLocalCheckoutTests(unittest.TestCase): + """find_local_checkout resolves ./app/audio.cpp and nothing else.""" + + def test_none_when_no_checkout_in_app_dir(self): + with tempfile.TemporaryDirectory() as td, \ + patch.object(make_server, "APP_DIR", Path(td)): + self.assertIsNone(make_server.find_local_checkout()) + + def test_returns_the_managed_checkout(self): + with tempfile.TemporaryDirectory() as td, \ + patch.object(make_server, "APP_DIR", Path(td)): + checkout = _make_checkout(Path(td)) + self.assertEqual(make_server.find_local_checkout(), checkout) + + def test_none_when_checkout_lacks_model_specs(self): + with tempfile.TemporaryDirectory() as td, \ + patch.object(make_server, "APP_DIR", Path(td)): + (Path(td) / "audio.cpp").mkdir() + self.assertIsNone(make_server.find_local_checkout()) + + class LoadModelCatalogTests(unittest.TestCase): def setUp(self): self._td = tempfile.TemporaryDirectory() @@ -1082,11 +1115,15 @@ class NonInteractiveMainTests(unittest.TestCase): def tearDown(self): self._td.cleanup() - def _run(self, argv, transcribe=None, whisper="faster_whisper"): + def _run(self, argv, transcribe=None, whisper="faster_whisper", + no_checkout=False): argv = ["backends/audiocpp.py"] + argv transcribe_effect = transcribe if transcribe is not None \ else MagicMock() with patch.object(sys, "argv", argv), \ + patch.object(make_server, "find_local_checkout", + return_value=None if no_checkout + else self.checkout), \ patch.object(make_server, "transcribe_reference_audio", side_effect=transcribe_effect), \ patch.object(make_server, "whisper_backend_available", @@ -1094,8 +1131,8 @@ class NonInteractiveMainTests(unittest.TestCase): return make_server.main() def _args(self, *extra): - return ["--wavs", str(self.folder), "--output", str(self.output), - "--audiocpp-dir", str(self.checkout)] + list(extra) + return ["--wavs", str(self.folder), "--output", str(self.output)] \ + + list(extra) def test_default_run_hosts_recommended_entry(self): exit_code = self._run( @@ -1198,18 +1235,16 @@ class NonInteractiveMainTests(unittest.TestCase): self.assertEqual(ctx.exception.code, 2) def test_missing_checkout_rejected(self): - with patch.object(make_server, "find_local_checkout", - return_value=None), \ - self.assertRaises(SystemExit) as ctx: + with self.assertRaises(SystemExit) as ctx: self._run(["--families", "higgs_audio_tts", "--output", - str(self.output), "--no-sync-model-ids"]) + str(self.output), "--no-sync-model-ids"], + no_checkout=True) self.assertEqual(ctx.exception.code, 2) def test_missing_wav_dir_rejected(self): missing = self.root / "nope" with self.assertRaises(SystemExit) as ctx: self._run(["--wavs", str(missing), "--output", str(self.output), - "--audiocpp-dir", str(self.checkout), "--families", "higgs_audio_tts", "--no-sync-model-ids"]) self.assertEqual(ctx.exception.code, 2) @@ -1822,19 +1857,25 @@ class UninstallTests(unittest.TestCase): checkout.mkdir() with patch.object(make_server, "find_local_checkout", return_value=checkout), \ + patch.object(make_server.servers, "pid_for", + return_value=1234), \ patch.object(make_server.servers, "stop") as mk_stop: rc = make_server.uninstall() self.assertEqual(rc, 0) self.assertFalse(checkout.exists()) mk_stop.assert_called_once_with("audiocpp") - def test_no_checkout_is_a_noop(self): + def test_skips_stop_without_a_pid_file(self): + # No pid file: the server was never started by this tool, so + # stop (and its "stop it manually" noise) is skipped. with patch.object(make_server, "find_local_checkout", return_value=None), \ + patch.object(make_server.servers, "pid_for", + return_value=None), \ patch.object(make_server.servers, "stop") as mk_stop: rc = make_server.uninstall() self.assertEqual(rc, 0) - mk_stop.assert_called_once_with("audiocpp") + mk_stop.assert_not_called() def test_accepts_task_view_kwargs_for_registry_symmetry(self): # The hub calls uninstall(emit=..., cancel=...); emit is unused here @@ -1844,6 +1885,8 @@ class UninstallTests(unittest.TestCase): checkout.mkdir() with patch.object(make_server, "find_local_checkout", return_value=checkout), \ + patch.object(make_server.servers, "pid_for", + return_value=1234), \ patch.object(make_server.servers, "stop"): rc = make_server.uninstall(emit=lambda line: None, cancel=None) @@ -1860,6 +1903,8 @@ class UninstallTests(unittest.TestCase): cancel.set() with patch.object(make_server, "find_local_checkout", return_value=checkout), \ + patch.object(make_server.servers, "pid_for", + return_value=1234), \ patch.object(make_server.servers, "stop"): rc = make_server.uninstall(cancel=cancel) self.assertEqual(rc, 130) -- cgit v1.2.3