aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-29 13:57:40 -0400
committerhistoria <historiavg@proton.me>2026-08-29 13:57:40 -0400
commitdf4a81c6101d33fe745b6ac249c736e088760c85 (patch)
tree7103e8e2d33a1c2b9afecd47d309147bbeed3c34 /app
parent8a128b3859b8f398e162d3168ff328ab3199d307 (diff)
downloadtts-audiobook-generator-df4a81c6101d33fe745b6ac249c736e088760c85.tar.gz
fix: crash on bad model_specs from audio.cpp, sanitized
Diffstat (limited to 'app')
-rw-r--r--app/backends/audiocpp/build.py55
-rw-r--r--app/backends/audiocpp/models.py194
-rw-r--r--app/docs/backend-audiocpp.md4
-rw-r--r--app/tests/test_backends_audiocpp.py268
-rw-r--r--app/tests/test_hub.py109
-rw-r--r--app/ui/hub.py16
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)