diff options
| -rw-r--r-- | app/backends/audiocpp/build.py | 55 | ||||
| -rw-r--r-- | app/backends/audiocpp/models.py | 194 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 4 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 268 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 109 | ||||
| -rw-r--r-- | app/ui/hub.py | 16 |
6 files changed, 586 insertions, 60 deletions
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py index e3536cf..36c304c 100644 --- a/app/backends/audiocpp/build.py +++ b/app/backends/audiocpp/build.py @@ -59,7 +59,11 @@ def update(*, emit=None, cancel=None) -> int: (a ``prebuilt.json`` marker next to the binary, see ``backends.audiocpp.prebuilt``) take a different route: they skip the git update and rebuild entirely and instead re-download when upstream - published a newer release (see ``_update_prebuilt``). Source-built + published a newer release (see ``_update_prebuilt``) — and when that + re-download fails (rate limit, offline), the same source-build route a + source-built checkout uses runs instead, so 'Update Backends' still + gets current by whatever means work; the installed binary is only + replaced on success and keeps working meanwhile. Source-built checkouts keep the original flow — phases: stop server / git update / rebuild — CANCEL is honored between phases only, so a started phase always completes. The git update is a fetch plus hard reset to origin's @@ -78,7 +82,8 @@ def update(*, emit=None, cancel=None) -> int: interrupted (cancelled or failed) earlier rebuild, which leaves the previous binary in place against already-updated sources. An up-to-date checkout with a fresh binary costs one fetch. Returns the - exit code (130 when cancelled before a remaining phase). + exit code (130 when cancelled before a remaining phase, or when a + prebuilt re-download was cancelled). """ # Only stop when a pid file exists: without one this tool never # started the server, so the "not started by this tool" notice would @@ -92,8 +97,19 @@ def update(*, emit=None, cancel=None) -> int: print("[INFO] No audio.cpp checkout to update.") return 0 backend = _rebuild_backend(checkout) + fell_back = False if _prebuilt.installed_release(checkout, backend) is not None: - return _update_prebuilt(checkout, backend, emit=emit, cancel=cancel) + rc = _update_prebuilt(checkout, backend, emit=emit, cancel=cancel) + if rc == 0 or rc == 130: + return rc + # The re-download failed (the usual cause is GitHub's API rate + # limit): the installed binary was left in place and keeps + # working — recover in place like the install paths do, by + # building from source instead. + print(f"[WARNING] prebuilt update failed (exit {rc}); the installed " + "audiocpp_server was left in place — falling back to a " + "source build...") + fell_back = True head_before = common.git_head(checkout) rc = common.git_update(checkout, emit=emit, cancel=cancel) if rc != 0: @@ -113,6 +129,8 @@ def update(*, emit=None, cancel=None) -> int: moved=head_after not in (None, head_before)): print(f"[OK] {checkout} is already at origin's HEAD with an " "up-to-date audiocpp_server.") + if fell_back: + _drop_prebuilt_marker(checkout, backend) return 0 if head_after in (None, head_before): print(f"[INFO] audiocpp_server on disk is older than the " @@ -130,9 +148,29 @@ def update(*, emit=None, cancel=None) -> int: "retry the rebuild.") else: print("[OK] rebuild complete.") + if fell_back: + _drop_prebuilt_marker(checkout, backend) return build_rc +def _drop_prebuilt_marker(checkout: Path, backend: Optional[str]) -> None: + """Remove the prebuilt.json marker after a fallback source build. + + The fallback replaced (or matched) the downloaded binary with a + source build, so the marker's "this build directory holds release + <tag>" claim is stale — keeping it would make the next update + re-download over the freshly built binary. Best-effort: a marker + that cannot be removed only means the next update re-downloads. + """ + marker = _prebuilt.marker_path(checkout, backend) + if marker is None: + return + try: + marker.unlink(missing_ok=True) + except OSError: + pass + + def _update_prebuilt(checkout: Path, backend: Optional[str], *, emit=None, cancel=None) -> int: """The update route for a prebuilt (release-downloaded) install. @@ -144,8 +182,10 @@ def _update_prebuilt(checkout: Path, backend: Optional[str], *, re-download itself, for the checksum digests. The recorded/detected backend selects the asset, mirroring what was originally installed. A GitHub outage is not fatal — the installed binary keeps working. - Returns the install exit code (0 when already current or when the - check could not run). + Returns 0 when already current or when the check could not run; a + failed re-download returns its exit code and ``update()`` falls back + to a source build (a cancelled download, 130, aborts without + falling back). """ marker = _prebuilt.installed_release(checkout, backend) tag = _prebuilt.resolve_latest_tag() @@ -166,11 +206,6 @@ def _update_prebuilt(checkout: Path, backend: Optional[str], *, cancel=cancel) if rc == 0: print(f"[OK] audiocpp_server updated to {tag}.") - else: - print(f"[WARNING] prebuilt update failed (exit {rc}); the " - "installed audiocpp_server was left in place. Retry " - "'Update Backends' later, or build from source with " - "'Build audio.cpp Server'.") return rc diff --git a/app/backends/audiocpp/models.py b/app/backends/audiocpp/models.py index 772ae8d..f2c4e5e 100644 --- a/app/backends/audiocpp/models.py +++ b/app/backends/audiocpp/models.py @@ -90,6 +90,14 @@ def _install_models(audiocpp_dir: Path, runs with ``--progress --cancel-file`` so the view can show a real byte progress bar and cancel gracefully. CANCEL aborts a running download. + When the checkout's model specs carry a broken ``strip_prefix`` (a dot + prefix over repo-root files — the manager rejects every file in such a + package) and the manager supports ``--specs-dir``, the installs run + against a sanitized temporary copy of the specs (see + ``_prepare_specs_dir``); the checkout itself is left untouched. Specs + that cannot be repaired confidently are left as-is: those installs + fail, are reported as warnings, and the remaining downloads continue. + Returns 0 when every command succeeded (or nothing needed running), 130 when cancelled, 1 when any download failed. """ @@ -122,45 +130,78 @@ def _install_models(audiocpp_dir: Path, return 0 failed = False - for _, install_id in pending: - print(f"[INFO] Downloading {install_id}...") - argv = [sys.executable, str(manager), "install", install_id] - cancel_file: Optional[Path] = None - on_cancel = None - if supports_progress: - fd, cancel_path = tempfile.mkstemp( - prefix="audiocpp_cancel_", suffix=".cancel") - os.close(fd) - cancel_file = Path(cancel_path) - cancel_file.unlink() # absent = not cancelled - argv += ["--progress", "--cancel-file", str(cancel_file)] - on_cancel = cancel_file.touch + specs_dir: Optional[Path] = None + if _manager_supports_flag(manager, "--specs-dir"): try: - rc = common.run_console_subprocess( - argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel, - on_cancel=on_cancel, - stall_timeout=(DOWNLOAD_STALL_TIMEOUT - if supports_progress else None)) + specs_dir = _prepare_specs_dir(audiocpp_dir) except OSError as exc: - print(f"[WARNING] Could not run python {manager} install " - f"{install_id}: {exc}") - rc = 1 - finally: - if cancel_file is not None: - try: - 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") + print(f"[WARNING] Could not prepare sanitized model specs: {exc}") + specs_dir = None + if specs_dir is not None: + print(f"[INFO] The checkout's model specs carry a broken " + "strip_prefix; installing from a sanitized copy " + f"({specs_dir})") + specs_args = (["--specs-dir", str(specs_dir)] + if specs_dir is not None else []) + try: + for _, install_id in pending: + print(f"[INFO] Downloading {install_id}...") + argv = [sys.executable, str(manager)] + specs_args + [ + "install", install_id] + cancel_file: Optional[Path] = None + on_cancel = None + if supports_progress: + fd, cancel_path = tempfile.mkstemp( + prefix="audiocpp_cancel_", suffix=".cancel") + os.close(fd) + cancel_file = Path(cancel_path) + cancel_file.unlink() # absent = not cancelled + argv += ["--progress", "--cancel-file", str(cancel_file)] + on_cancel = cancel_file.touch + try: + rc = common.run_console_subprocess( + argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel, + on_cancel=on_cancel, + stall_timeout=(DOWNLOAD_STALL_TIMEOUT + if supports_progress else None)) + except OSError as exc: + print(f"[WARNING] Could not run python {manager} install " + f"{install_id}: {exc}") + rc = 1 + finally: + if cancel_file is not None: + try: + 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") + finally: + if specs_dir is not None: + shutil.rmtree(specs_dir, ignore_errors=True) return 1 if failed else 0 +def _manager_supports_flag(manager: Path, flag: str) -> bool: + """True when MANAGER's (model_manager_v2.py's) source mentions FLAG. + + The checkout is downloaded, so an older copy may lack a relatively + recent flag; probing the script source once is cheaper than failing an + install with an unknown option. A false positive (the string appears + outside argparse) is caught when the subprocess reports the error. + """ + try: + text = manager.read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + return flag in text + + def _manager_supports_progress(manager: Path) -> bool: """True when MANAGER (model_manager_v2.py) supports --progress output. @@ -168,11 +209,90 @@ def _manager_supports_progress(manager: Path) -> bool: older audio.cpp checkout may not have them, so probe the script source once instead of failing the download with an unknown flag. """ + return (_manager_supports_flag(manager, "AUDIOCPP_PROGRESS") + and _manager_supports_flag(manager, "--cancel-file")) + + +def _sanitize_model_spec(spec: dict) -> bool: + """Repair dot ``strip_prefix`` packages in SPEC, in place. + + A package's ``strip_prefix`` is stripped from the front of every file + path to get the local layout, so it only works when every file is + listed under that prefix (``<prefix>/<file>``). A dot prefix ("." or + "./") is meant for files written ``./<file>``; when the package instead + lists repo-root files bare (``model.gguf``), the manager rejects the + whole package ("file path does not start with strip_prefix '.': ...") + and nothing can be downloaded. Root-level files need no prefix at all + (upstream specs like minimax_music3.json store ""), so dropping the dot + prefix is the safe repair. Prefixes naming a real directory are left + alone — the correct remote paths cannot be guessed. Returns True when + SPEC changed. + """ + changed = False + for package in spec.get("packages") or []: + if not isinstance(package, dict): + continue + prefix = str(package.get("strip_prefix") or "").rstrip("/") + if prefix not in (".", ".."): + continue + files = package.get("files") + if not isinstance(files, list) or not files: + continue + if all(isinstance(item, str) and item.startswith(prefix + "/") + for item in files): + continue + package["strip_prefix"] = "" + changed = True + return changed + + +def _prepare_specs_dir(audiocpp_dir: Path) -> Optional[Path]: + """Return a temp specs dir with dot ``strip_prefix`` entries repaired. + + audio.cpp's model manager accepts ``--specs-dir``, so a checkout whose + specs carry a broken ``strip_prefix`` can be installed from a sanitized + copy without modifying the checkout. Every ``model_specs/*.json`` is + copied; the ones needing a repair are rewritten via + ``_sanitize_model_spec`` (specs that fail to parse are copied verbatim + so the manager reports them exactly as it would upstream). Returns None + when no spec needed a repair (or the specs directory is missing or + unreadable) — the caller then runs against the checkout's own specs. + The caller owns the returned directory and removes it when the installs + are done. + """ + specs_dir = audiocpp_dir / "model_specs" try: - text = manager.read_text(encoding="utf-8", errors="ignore") + spec_paths = sorted(specs_dir.glob("*.json")) except OSError: - return False - return "AUDIOCPP_PROGRESS" in text and "--cancel-file" in text + return None + if not spec_paths: + return None + payloads: List[Tuple[str, str]] = [] + sanitized = False + for spec_path in spec_paths: + try: + text = spec_path.read_text(encoding="utf-8") + except OSError: + return None + try: + spec = json.loads(text) + except ValueError: + payloads.append((spec_path.name, text)) + continue + if isinstance(spec, dict) and _sanitize_model_spec(spec): + sanitized = True + text = json.dumps(spec, indent=2, ensure_ascii=False) + "\n" + payloads.append((spec_path.name, text)) + if not sanitized: + return None + staging = Path(tempfile.mkdtemp(prefix="audiocpp_specs_")) + try: + for name, payload in payloads: + (staging / name).write_text(payload, encoding="utf-8") + except OSError: + shutil.rmtree(staging, ignore_errors=True) + raise + return staging def download_applicable(audiocpp_dir: Path, model_entries: List[dict]) -> bool: diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 276bf08..32ce39b 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -6,13 +6,13 @@ The easiest way is the TUI: run `python audiobook.py`, choose **Configure Backen The hub's backend status table distinguishes how far audio.cpp is set up: `unavailable` (nothing present), `downloaded (not built)` (checkout cloned, `audiocpp_server` not installed), `built (not configured)` (binary installed, no `server.json`), `installed` (ready; or `installed (models missing)` when the config references undownloaded models), and `running` once its server answers. Whenever the checkout exists but `audiocpp_server` is missing, **Configure Backends… → Build audio.cpp Server** installs it from the TUI — asking whether to download the prebuilt release (recommended, macOS and Windows) or build from source (the wizard offers the same choice during setup), so a backend whose install you skipped is never stuck as "unavailable". On a fresh install the setup is one continuous flow: clone → configure → and then the install and the model downloads run **simultaneously** in a split view (half downloading/building, half downloading models). The setup steps are therefore ordered install > configure > download, and **Build audio.cpp Server** and **Download Missing Models (audio.cpp)** are never offered at the same time; **Build audio.cpp Server** downloads any missing models alongside the install, and **Download Missing Models (audio.cpp)** remains only as a fallback for when a download fails or is interrupted. -Prebuilt installs are tracked with a `prebuilt.json` marker inside the build directory. **Update Backends** then skips the git pull/rebuild flow for those and instead re-downloads when upstream publishes a newer release (and checks the checkout out at the release's tag, keeping its model catalog and tooling in sync with the binary). A source-built checkout keeps updating by git pull + rebuild. +Prebuilt installs are tracked with a `prebuilt.json` marker inside the build directory. **Update Backends** then skips the git pull/rebuild flow for those and instead re-downloads when upstream publishes a newer release (and checks the checkout out at the release's tag, keeping its model catalog and tooling in sync with the binary). When that re-download fails (usually GitHub's API rate limit), **Update Backends** falls back to the same source-build route a source-built checkout uses — the installed binary is only replaced once a new one is in place, so it keeps working meanwhile — and a successful fallback build removes the now-stale marker so later updates take the git + rebuild route. A source-built checkout keeps updating by git pull + rebuild. If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. ### Download or build audiocpp_server -The wizard's default on macOS and Windows is to download a prebuilt `audiocpp_server` from [audio.cpp's releases](https://github.com/0xShug0/audio.cpp/releases) (checksum-verified, ~20–25 MB, no compiler or Xcode required — on Intel Macs the release runs on CPU only, and Windows CUDA builds come in 12.4/13.3 variants picked to match your NVIDIA driver). The newest version is resolved without GitHub's API, so the check rarely hits the API's rate limit; if the API *is* rate-limited when a download starts, the install proceeds without checksum verification (with a loud warning). If the download still fails, the TUI does not leave you half-installed: the same Build lane automatically falls back to building from source (a cancelled download, or an explicit `--prebuilt yes` — which exists to forbid source builds — do not fall back). **Update Backends** keeps a prebuilt install current automatically. The manual build steps, if you prefer building from source, are below — build for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. audio.cpp ships one helper script per platform, and the hub runs the one matching your OS: +The wizard's default on macOS and Windows is to download a prebuilt `audiocpp_server` from [audio.cpp's releases](https://github.com/0xShug0/audio.cpp/releases) (checksum-verified, ~20–25 MB, no compiler or Xcode required — on Intel Macs the release runs on CPU only, and Windows CUDA builds come in 12.4/13.3 variants picked to match your NVIDIA driver). The newest version is resolved without GitHub's API, so the check rarely hits the API's rate limit; if the API *is* rate-limited when a download starts, the install proceeds without checksum verification (with a loud warning). If the download still fails, the TUI does not leave you half-installed: the same Build lane automatically falls back to building from source (a cancelled download, or an explicit `--prebuilt yes` — which exists to forbid source builds — do not fall back). **Update Backends** keeps a prebuilt install current automatically, and falls back to a source build the same way when its re-download fails. The manual build steps, if you prefer building from source, are below — build for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. audio.cpp ships one helper script per platform, and the hub runs the one matching your OS: ```bash # Linux diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index ada7954..08de777 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -834,6 +834,203 @@ class InstallModelsTests(unittest.TestCase): self.assertFalse(make_server.models._all_models_present( self.checkout, [{"path": "models/higgs"}])) + def _supporting_manager(self): + self.manager.write_text( + '#!/usr/bin/env python3\n' + 'parser.add_argument("--specs-dir", default="")\n' + 'parser.add_argument("--cancel-file", default="")\n', + encoding="utf-8") + + def _write_spec(self, name: str, packages: list) -> Path: + specs = self.checkout / "model_specs" + specs.mkdir(parents=True, exist_ok=True) + path = specs / name + path.write_text(json.dumps({"family": name[:-5], "packages": packages}), + encoding="utf-8") + return path + + def test_dot_strip_prefix_spec_installs_from_sanitized_copy(self): + self._supporting_manager() + original = self._write_spec("vietneu_tts.json", [{ + "id": "vietneu_tts_v3_turbo_q8_0", + "files": ["model.gguf"], + "strip_prefix": ".", + }]) + self._write_spec("other.json", [{ + "id": "other_q8_0", + "files": ["Other-GGUF/model.gguf"], + "strip_prefix": "Other-GGUF", + }]) + seen: dict = {} + + def record(argv, **kwargs): + specs_dir = Path(argv[3]) + seen["fixed"] = json.loads( + (specs_dir / "vietneu_tts.json").read_text(encoding="utf-8")) + seen["other"] = json.loads( + (specs_dir / "other.json").read_text(encoding="utf-8")) + return 0 + + buf = io.StringIO() + with redirect_stdout(buf), \ + patch.object(common, "run_console_subprocess", + side_effect=record) as run: + rc = make_server.models._install_models( + self.checkout, + [("VieNeu-TTS v3 Turbo GGUF", "vietneu_tts_v3_turbo_q8_0")], + download=True) + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertEqual(argv[:2], [sys.executable, str(self.manager)]) + self.assertEqual(argv[2], "--specs-dir") + self.assertEqual(argv[4:], ["install", "vietneu_tts_v3_turbo_q8_0"]) + self.assertEqual(seen["fixed"]["packages"][0]["strip_prefix"], "") + self.assertEqual(seen["other"]["packages"][0]["strip_prefix"], + "Other-GGUF") + self.assertIn("sanitized copy", buf.getvalue()) + # The checkout's own specs are untouched and the temp copy is gone. + self.assertEqual(json.loads( + original.read_text(encoding="utf-8"))["packages"][0] + ["strip_prefix"], ".") + self.assertFalse(Path(argv[3]).exists()) + + def test_healthy_specs_do_not_add_specs_dir(self): + self._supporting_manager() + self._write_spec("ok.json", [{ + "id": "ok_q8_0", + "files": ["Ok-GGUF/model.gguf"], + "strip_prefix": "Ok-GGUF", + }]) + with patch.object(common, "run_console_subprocess", + return_value=0) as run: + make_server.models._install_models( + self.checkout, [("Ok", "ok_q8_0")], download=True) + self.assertEqual( + run.call_args[0][0], + [sys.executable, str(self.manager), "install", "ok_q8_0"]) + + def test_unknown_prefix_mismatch_left_for_warning_path(self): + # A real-directory prefix that matches no files cannot be repaired + # confidently; the install is left to fail with the manager's own + # error so the remaining downloads continue (warn-and-continue). + self._supporting_manager() + self._write_spec("broken.json", [{ + "id": "broken_q8_0", + "files": ["model.gguf"], + "strip_prefix": "Some-Dir", + }]) + with patch.object(common, "run_console_subprocess", + return_value=0) as run: + make_server.models._install_models( + self.checkout, [("Broken", "broken_q8_0")], download=True) + self.assertEqual( + run.call_args[0][0], + [sys.executable, str(self.manager), "install", "broken_q8_0"]) + + def test_specs_dir_unsupported_manager_leaves_argv_unchanged(self): + self._write_spec("vietneu_tts.json", [{ + "id": "vietneu_tts_v3_turbo_q8_0", + "files": ["model.gguf"], + "strip_prefix": ".", + }]) + with patch.object(common, "run_console_subprocess", + return_value=0) as run: + make_server.models._install_models( + self.checkout, + [("VieNeu-TTS v3 Turbo GGUF", "vietneu_tts_v3_turbo_q8_0")], + download=True) + self.assertEqual( + run.call_args[0][0], + [sys.executable, str(self.manager), "install", + "vietneu_tts_v3_turbo_q8_0"]) + + +class SanitizeModelSpecTests(unittest.TestCase): + """The dot strip_prefix repair and the --specs-dir staging copy.""" + + def test_dot_prefix_dropped_when_file_is_bare(self): + spec = {"packages": [{"files": ["model.gguf"], "strip_prefix": "."}]} + self.assertTrue(make_server.models._sanitize_model_spec(spec)) + self.assertEqual(spec["packages"][0]["strip_prefix"], "") + + def test_slash_dot_prefix_normalized_like_dot(self): + spec = {"packages": [{"files": ["model.gguf"], "strip_prefix": "./"}]} + self.assertTrue(make_server.models._sanitize_model_spec(spec)) + self.assertEqual(spec["packages"][0]["strip_prefix"], "") + + def test_dot_prefix_kept_when_files_carry_it(self): + spec = {"packages": [{"files": ["./model.gguf"], + "strip_prefix": "."}]} + self.assertFalse(make_server.models._sanitize_model_spec(spec)) + self.assertEqual(spec["packages"][0]["strip_prefix"], ".") + + def test_real_directory_prefix_untouched(self): + spec = {"packages": [{"files": ["model.gguf"], + "strip_prefix": "Kroko-ASR-GGUF"}]} + self.assertFalse(make_server.models._sanitize_model_spec(spec)) + self.assertEqual(spec["packages"][0]["strip_prefix"], + "Kroko-ASR-GGUF") + + def test_valid_prefix_untouched(self): + spec = {"packages": [{"files": ["Kroko-ASR-GGUF/model.gguf"], + "strip_prefix": "Kroko-ASR-GGUF"}]} + self.assertFalse(make_server.models._sanitize_model_spec(spec)) + + def test_missing_or_empty_files_untouched(self): + spec = {"packages": [{"strip_prefix": "."}, + {"files": [], "strip_prefix": "."}, + {"files": "model.gguf", "strip_prefix": "."}]} + self.assertFalse(make_server.models._sanitize_model_spec(spec)) + + def test_only_broken_packages_repaired(self): + spec = {"packages": [ + {"files": ["model.gguf"], "strip_prefix": "."}, + {"files": ["./model.gguf"], "strip_prefix": "."}, + ]} + self.assertTrue(make_server.models._sanitize_model_spec(spec)) + self.assertEqual([p["strip_prefix"] for p in spec["packages"]], + ["", "."]) + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = Path(self._td.name) / "audio.cpp" + self.checkout.mkdir() + + def tearDown(self): + self._td.cleanup() + + def test_prepare_specs_dir_none_without_specs(self): + self.assertIsNone( + make_server.models._prepare_specs_dir(self.checkout)) + + def test_prepare_specs_dir_none_when_healthy(self): + specs = self.checkout / "model_specs" + specs.mkdir() + (specs / "ok.json").write_text(json.dumps( + {"packages": [{"files": ["Ok-GGUF/m.gguf"], + "strip_prefix": "Ok-GGUF"}]}), encoding="utf-8") + self.assertIsNone( + make_server.models._prepare_specs_dir(self.checkout)) + + def test_prepare_specs_dir_writes_all_specs_and_repairs(self): + specs = self.checkout / "model_specs" + specs.mkdir() + (specs / "broken.json").write_text(json.dumps( + {"packages": [{"files": ["model.gguf"], + "strip_prefix": "."}]}), encoding="utf-8") + (specs / "plain.json").write_text("not json", encoding="utf-8") + staging = make_server.models._prepare_specs_dir(self.checkout) + try: + self.assertIsNotNone(staging) + repaired = json.loads( + (staging / "broken.json").read_text(encoding="utf-8")) + self.assertEqual(repaired["packages"][0]["strip_prefix"], "") + self.assertEqual( + (staging / "plain.json").read_text(encoding="utf-8"), + "not json") + finally: + shutil.rmtree(staging, ignore_errors=True) + class ConfigFormTranscriptionToggleTests(unittest.TestCase): """The combined form's Voice transcripts row: one fixed two-way toggle. @@ -3467,10 +3664,18 @@ class PrebuiltUpdateRoutingTests(unittest.TestCase): marker.write_text(json.dumps({"tag": tag, "asset": "x"}), encoding="utf-8") + def _marker(self, checkout: Path) -> Path: + return make_server.prebuilt.marker_path(checkout, "cpu", + platform="darwin") + def _run(self, checkout: Path, *, marker: Optional[dict], - release: Optional[dict], tag: Optional[str] = None): + release: Optional[dict], tag: Optional[str] = None, + install_rc: int = 0, build_rc: int = 0): # TAG is what the quota-free redirect resolution reports; RELEASE # is the API fallback (only consulted when the redirect fails). + # INSTALL_RC is what the prebuilt re-download reports: 0 (or a + # cancellation) returns directly, a failure falls back to the + # source-build route, whose BUILD_RC decides the final exit code. with patch("sys.platform", "darwin"), \ patch.object(make_server.build, "find_local_checkout", return_value=checkout), \ @@ -3485,16 +3690,18 @@ class PrebuiltUpdateRoutingTests(unittest.TestCase): patch.object(make_server.prebuilt, "fetch_latest_release", return_value=release) as mk_fetch, \ patch.object(make_server.prebuilt, "install_prebuilt", - return_value=0) as mk_install, \ + return_value=install_rc) as mk_install, \ patch.object(common, "git_update", - return_value=0) as mk_git: + return_value=0) as mk_git, \ + patch.object(make_server.build, "build_audiocpp", + return_value=build_rc) as mk_build: rc = make_server.build.update() - return rc, mk_install, mk_git, mk_tag, mk_fetch + return rc, mk_install, mk_git, mk_tag, mk_fetch, mk_build def test_newer_release_triggers_a_redownload(self): checkout = self._checkout() self._mark_prebuilt(checkout, "v0.6.0") - rc, mk_install, mk_git, _mk_tag, mk_fetch = self._run( + rc, mk_install, mk_git, _mk_tag, mk_fetch, mk_build = self._run( checkout, marker={"tag": "v0.6.0", "asset": "x"}, release=_release([], tag="v0.7.0"), tag="v0.7.0") self.assertEqual(rc, 0) @@ -3502,22 +3709,24 @@ class PrebuiltUpdateRoutingTests(unittest.TestCase): mk_install.assert_called_once_with(checkout, "cpu", emit=None, cancel=None) mk_fetch.assert_not_called() + mk_build.assert_not_called() def test_current_release_is_a_noop(self): checkout = self._checkout() - rc, mk_install, mk_git, _mk_tag, mk_fetch = self._run( + rc, mk_install, mk_git, _mk_tag, mk_fetch, mk_build = self._run( checkout, marker={"tag": "v0.7.0", "asset": "x"}, release=None, tag="v0.7.0") self.assertEqual(rc, 0) mk_install.assert_not_called() mk_git.assert_not_called() + mk_build.assert_not_called() # The redirect already answered: the API (rate-limited easily) # must not be touched for an "already current" check. mk_fetch.assert_not_called() def test_unreachable_github_keeps_the_install(self): checkout = self._checkout() - rc, mk_install, mk_git, mk_tag, mk_fetch = self._run( + rc, mk_install, mk_git, mk_tag, mk_fetch, mk_build = self._run( checkout, marker={"tag": "v0.7.0", "asset": "x"}, release=None, tag=None) self.assertEqual(rc, 0) @@ -3525,6 +3734,51 @@ class PrebuiltUpdateRoutingTests(unittest.TestCase): mk_fetch.assert_called_once() # the API fallback tried too mk_install.assert_not_called() mk_git.assert_not_called() + mk_build.assert_not_called() + + def test_failed_redownload_falls_back_to_a_source_build(self): + checkout = self._checkout() + self._mark_prebuilt(checkout, "v0.6.0") + rc, mk_install, mk_git, _mk_tag, _mk_fetch, mk_build = self._run( + checkout, marker={"tag": "v0.6.0", "asset": "x"}, + release=_release([], tag="v0.7.0"), tag="v0.7.0", install_rc=1) + self.assertEqual(rc, 0) + mk_install.assert_called_once_with(checkout, "cpu", emit=None, + cancel=None) + mk_git.assert_called_once() + mk_build.assert_called_once_with(checkout, "cpu", emit=None, + cancel=None) + # The fallback source build replaces the prebuilt install: the + # marker must go, or the next update re-downloads over the + # freshly built binary. + self.assertFalse(self._marker(checkout).exists()) + + def test_failed_redownload_keeps_the_marker_when_the_build_fails(self): + # A failed fallback leaves the previous prebuilt binary in place + # (install_prebuilt downloads before touching it), so the marker + # stays truthful and the next update retries the re-download. + checkout = self._checkout() + self._mark_prebuilt(checkout, "v0.6.0") + rc, _mk_install, mk_git, _mk_tag, _mk_fetch, mk_build = self._run( + checkout, marker={"tag": "v0.6.0", "asset": "x"}, + release=_release([], tag="v0.7.0"), tag="v0.7.0", install_rc=1, + build_rc=1) + self.assertEqual(rc, 1) + mk_git.assert_called_once() + mk_build.assert_called_once() + self.assertTrue(self._marker(checkout).exists()) + + def test_cancelled_redownload_does_not_fall_back(self): + checkout = self._checkout() + self._mark_prebuilt(checkout, "v0.6.0") + rc, _mk_install, mk_git, _mk_tag, _mk_fetch, mk_build = self._run( + checkout, marker={"tag": "v0.6.0", "asset": "x"}, + release=_release([], tag="v0.7.0"), tag="v0.7.0", + install_rc=130) + self.assertEqual(rc, 130) + mk_git.assert_not_called() + mk_build.assert_not_called() + self.assertTrue(self._marker(checkout).exists()) def test_source_builds_still_route_through_git(self): checkout = self._checkout() diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 39b8c5a..de7e4ea 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1031,6 +1031,115 @@ class ConvertFlowTests(unittest.TestCase): instr = self._field("instructions") self.assertTrue(instr["visible"](fields)) + def test_audiocpp_model_switch_keeps_the_picked_voice(self): + # Switching models whose voice list is unchanged (two clone + # entries sharing one server's voices) keeps the picked voice + # instead of snapping back to the list's first entry. + self._patch_remote( + [{"id": "alpha", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "beta", "family": "qwen3_tts", "task": "tts"}], + voices=["narrator", "second"]) + self._answer_form(backend="audiocpp-remote", model_id="alpha", + audiocpp_voice="second", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field = self._field("audiocpp_voice") + # The form opens on the list's first voice; the user picks another. + self.assertEqual(voice_field["value"], "narrator") + voice_field["value"] = "second" + model_field["value"] = "beta" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], "second") + model_field["value"] = "alpha" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], "second") + + def test_audiocpp_local_model_switch_keeps_the_picked_voice(self): + # The managed entry's voice list is shared by every model in + # server.json, so switching models keeps the picked voice. + with tempfile.TemporaryDirectory() as td: + root = Path(td) + (root / "server.json").write_text(json.dumps({ + "models": [{"id": "qwen-1_7b", "family": "qwen3_tts", + "task": "tts"}, + {"id": "qwen-0_6b", "family": "qwen3_tts", + "task": "tts"}], + "voice_dir": str(root), + }), encoding="utf-8") + (root / "Narrator.wav").write_bytes(b"x") + (root / "Second.wav").write_bytes(b"x") + with patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=root): + self._answer_form(backend="audiocpp", model_id="qwen-1_7b", + audiocpp_voice="Second", instructions="") + self._convert(None, + [self._ready("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field = self._field("audiocpp_voice") + self.assertEqual(voice_field["value"], "Narrator") + voice_field["value"] = "Second" + model_field["value"] = "qwen-0_6b" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], "Second") + + def test_audiocpp_model_switch_resets_when_the_pick_is_gone(self): + # A remote server may host different voices per model: switching + # to a model whose list no longer offers the pick falls back to + # that model's first voice (and re-points again on the way back). + models = patch.object( + hub.audiocpp_backend, "fetch_server_models", + lambda url: [{"id": "alpha", "family": "higgs_audio_tts", + "task": "tts"}, + {"id": "beta", "family": "qwen3_tts", + "task": "tts"}]) + voices = patch.object( + hub.audiocpp_backend, "fetch_server_voices", + lambda url, model_id: {"alpha": ["narrator", "second"], + "beta": ["other"]}[model_id]) + with models, voices: + self._answer_form(backend="audiocpp-remote", model_id="alpha", + audiocpp_voice="second", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field = self._field("audiocpp_voice") + model_field["value"] = "beta" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], "other") + model_field["value"] = "alpha" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], "narrator") + + def test_audiocpp_model_switch_resets_across_capabilities(self): + # Keep-the-pick only applies within one voice list: a clone pick + # never survives a move to a built-in-speaker entry (and vice + # versa), and a design entry clears the voice again. + self._patch_remote( + [{"id": "clone", "family": "higgs_audio_tts", "task": "tts"}, + {"id": "Qwen3-TTS-CustomVoice-GGUF", "family": "qwen3_tts", + "task": "tts"}, + {"id": "design", "family": "qwen3_tts", "task": "vdes"}], + voices=["narrator", "second"]) + self._answer_form(backend="audiocpp-remote", model_id="clone", + audiocpp_voice="second", instructions="") + self._convert(None, [self._remote("audiocpp", "audio.cpp")]) + fields = self.tui.forms_seen[0][1] + model_field = self._field("model_id") + voice_field = self._field("audiocpp_voice") + voice_field["value"] = "second" + model_field["value"] = "Qwen3-TTS-CustomVoice-GGUF" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], hub.QWEN3_TTS_SPEAKERS[0]) + voice_field["value"] = "Ryan" + model_field["value"] = "clone" + model_field["on_change"](fields) + self.assertEqual(voice_field["value"], "narrator") + model_field["value"] = "design" + model_field["on_change"](fields) + self.assertIsNone(voice_field["value"]) + def test_audiocpp_remote_without_voices_refuses_generate_with_hint(self): # A clone-capable entry (e.g. Qwen Base) whose server lists no # voices at all: the Voice picker stays visible but is empty — diff --git a/app/ui/hub.py b/app/ui/hub.py index 24c779f..ef41d0b 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -1163,17 +1163,25 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None, entry.get("id") or "") def reset_voice(fields) -> None: - """Re-point the Voice field at the newly selected model's voice.""" + """Re-point the Voice field at the newly selected model's voice. + + A model switch that keeps the same voice list (two clone entries + sharing one server's voices) keeps the current pick: only a value + the new list cannot offer is re-pointed at its default. + """ voice_field = next(f for f in fields if f.get("key") == prefix + "audiocpp_voice") capability = model_capability(fields) if capability == AUDIOCPP_VOICE_DESIGN: voice_field["value"] = None - elif capability == AUDIOCPP_VOICE_SPEAKER: - voice_field["value"] = QWEN3_TTS_SPEAKERS[0] + return + if capability == AUDIOCPP_VOICE_SPEAKER: + voices = QWEN3_TTS_SPEAKERS else: # clone voices = voices_for(_field_value(fields, prefix + "model_id")) - voice_field["value"] = voices[0] if voices else "" + if voice_field.get("value") in voices: + return # the new list still offers the pick: keep it + voice_field["value"] = voices[0] if voices else "" def voice_choices(fields) -> list: capability = model_capability(fields) |
