aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/backends/audiocpp/__init__.py9
-rw-r--r--app/backends/audiocpp/build.py120
-rw-r--r--app/backends/audiocpp/catalog.py2
-rw-r--r--app/backends/audiocpp/prebuilt.py506
-rw-r--r--app/backends/audiocpp/wizard.py220
-rw-r--r--app/docs/backend-audiocpp.md22
-rw-r--r--app/tests/test_backends_audiocpp.py521
7 files changed, 1328 insertions, 72 deletions
diff --git a/app/backends/audiocpp/__init__.py b/app/backends/audiocpp/__init__.py
index 02fe967..2bb9cc7 100644
--- a/app/backends/audiocpp/__init__.py
+++ b/app/backends/audiocpp/__init__.py
@@ -68,6 +68,12 @@ from .build import (
uninstall,
update,
)
+from .prebuilt import (
+ fetch_latest_release,
+ install_prebuilt,
+ installed_release,
+ select_assets,
+)
from .remote import fetch_server_models, fetch_server_voices
from .wizard import (
build_parser,
@@ -100,6 +106,9 @@ __all__ = [
# build
"find_local_checkout", "find_audiocpp_server_bin", "find_build_script",
"apply_ggml_patches", "build_audiocpp", "uninstall", "update",
+ # prebuilt
+ "fetch_latest_release", "install_prebuilt", "installed_release",
+ "select_assets",
# remote
"fetch_server_models", "fetch_server_voices",
# wizard / status
diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py
index 22d2fa1..f840193 100644
--- a/app/backends/audiocpp/build.py
+++ b/app/backends/audiocpp/build.py
@@ -14,6 +14,7 @@ import logging_kit
from backends import common, servers
from backends.common import APP_DIR
+from . import prebuilt as _prebuilt
from .catalog import _BACKEND_TOKEN_RE, detect_backend, load_server_config
from .constants import AUDIOCPP_DIR_NAME, BACKENDS, PATCH_DIR
@@ -54,14 +55,19 @@ def update(*, emit=None, cancel=None) -> int:
"""Update the audio.cpp backend: refresh the checkout, rebuild if stale.
A managed server that is running is stopped first (best-effort): it
- serves the binary whose sources are being replaced. 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 HEAD (see ``common.git_update``): everything
- that matters lives untracked in the checkout (models, build trees,
- server.json) and survives, while the vendored-ggml patch edit is
- intentionally wiped — the rebuild re-applies it (the patch step is
- idempotent and fails loudly when upstream re-shaped the file).
+ serves the binary whose sources are being replaced. Prebuilt installs
+ (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
+ 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
+ HEAD (see ``common.git_update``): everything that matters lives
+ untracked in the checkout (models, build trees, server.json) and
+ survives, while the vendored-ggml patch edit is intentionally wiped —
+ the rebuild re-applies it (the patch step is idempotent and fails
+ loudly when upstream re-shaped the file).
The rebuild target is the backend recorded in server.json, else the
one detected from existing build directories; when neither names one
@@ -85,6 +91,9 @@ def update(*, emit=None, cancel=None) -> int:
if checkout is None:
print("[INFO] No audio.cpp checkout to update.")
return 0
+ backend = _rebuild_backend(checkout)
+ if _prebuilt.installed_release(checkout, backend) is not None:
+ return _update_prebuilt(checkout, backend, emit=emit, cancel=cancel)
head_before = common.git_head(checkout)
rc = common.git_update(checkout, emit=emit, cancel=cancel)
if rc != 0:
@@ -94,7 +103,6 @@ def update(*, emit=None, cancel=None) -> int:
head_after = common.git_head(checkout)
if common.cancel_requested(cancel):
return 130
- backend = _rebuild_backend(checkout)
if backend is None:
print("[INFO] audiocpp_server was never built for a known "
"backend; skipping the rebuild. 'Build audio.cpp Server' "
@@ -125,6 +133,43 @@ def update(*, emit=None, cancel=None) -> int:
return build_rc
+def _update_prebuilt(checkout: Path, backend: Optional[str], *,
+ emit=None, cancel=None) -> int:
+ """The update route for a prebuilt (release-downloaded) install.
+
+ Source builds update by rebuilding after a git pull; a prebuilt
+ install instead re-downloads when upstream published a newer release
+ (the marker's tag is compared against the latest release tag). 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).
+ """
+ marker = _prebuilt.installed_release(checkout, backend)
+ release = _prebuilt.fetch_latest_release()
+ if release is None:
+ print("[WARNING] Could not check GitHub for a newer prebuilt "
+ "audiocpp_server; keeping the installed one ("
+ f"{marker.get('tag') if marker else 'unknown'}).")
+ return 0
+ tag = str(release.get("tag_name") or "")
+ if marker and tag and tag == marker.get("tag"):
+ print(f"[OK] Prebuilt audiocpp_server is current ({tag}).")
+ return 0
+ print(f"[INFO] audio.cpp {tag} is available; downloading the prebuilt "
+ f"audiocpp_server ({backend})...")
+ rc = _prebuilt.install_prebuilt(checkout, backend, emit=emit,
+ 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
+
+
def _rebuild_needed(checkout: Path, binary: Optional[Path],
*, moved: bool) -> bool:
"""True when audiocpp_server must be (re)built after an update.
@@ -464,6 +509,49 @@ def apply_ggml_patches(audiocpp_dir: Path, *, emit=None, cancel=None) -> int:
return 0
+def _metal_compiler_available() -> bool:
+ """Whether Xcode's offline Metal shader compiler is installed.
+
+ ``build_metal.sh`` hard-requires it (``xcrun --find metal``), but the
+ compiler ships only with full Xcode — Command Line Tools report
+ ``unable to find utility "metal"``. When it is missing the darwin
+ build falls back to direct cmake with ``GGML_METAL_EMBED_LIBRARY=ON``:
+ the ggml Metal build then only embeds the shader *source* and macOS's
+ built-in Metal runtime compiles it on first GPU init, so Apple's
+ Command Line Tools (clang) plus cmake are enough.
+ """
+ proc = common.run_console_subprocess_quiet(
+ ["xcrun", "--sdk", "macosx", "--find", "metal"], timeout=30)
+ return proc is not None and proc.returncode == 0
+
+
+def _darwin_cmake_script() -> str:
+ """The one-line cmake invocation replacing build_metal.sh's two steps.
+
+ Mirrors the script's defaults for our target (RelWithDebInfo, OpenMP
+ off, llamafile/native-CPU on, deployment build on) plus
+ ``GGML_METAL_EMBED_LIBRARY=ON`` — the flag that makes the build
+ independent of the offline Metal compiler (see
+ ``_metal_compiler_available``). The build directory is the same
+ ``build/macos-metal-release`` the script uses, so backend detection
+ (``-metal-`` -> ``cpu``) is unaffected. Relative paths: the command
+ runs with cwd = the checkout.
+ """
+ build_dir = "build/macos-metal-release"
+ jobs = os.cpu_count() or 4
+ return (
+ "cmake -S . -B " + build_dir + " -DCMAKE_BUILD_TYPE=RelWithDebInfo"
+ " -DENGINE_ENABLE_CUDA=OFF -DENGINE_ENABLE_VULKAN=OFF"
+ " -DENGINE_ENABLE_METAL=ON -DENGINE_ENABLE_OPENMP=OFF"
+ " -DGGML_OPENMP=OFF -DENGINE_ENABLE_LLAMAFILE=ON"
+ " -DENGINE_ENABLE_NATIVE_CPU=ON"
+ " -DGGML_METAL_EMBED_LIBRARY=ON"
+ " -DAUDIOCPP_DEPLOYMENT_BUILD=ON"
+ f" && cmake --build {build_dir} --parallel {jobs}"
+ " --target audiocpp_server"
+ )
+
+
def build_audiocpp(audiocpp_dir: Path, backend: str, *,
emit=None, cancel=None) -> int:
"""Build audiocpp_server for BACKEND, streaming output.
@@ -476,6 +564,12 @@ def build_audiocpp(audiocpp_dir: Path, backend: str, *,
Windows (presets ``windows-{cuda,vulkan,cpu}-release``), and
``build_linux.sh`` under bash everywhere else.
+ macOS exception: when Xcode's offline Metal compiler is missing (only
+ full Xcode ships it), ``build_metal.sh`` would abort at its probe —
+ the build instead runs cmake directly with the Metal shaders embedded
+ as source (see ``_darwin_cmake_script``), so the Apple Command Line
+ Tools plus cmake are enough.
+
With EMIT None the build script runs on the console (inherits the
terminal); with EMIT given (the in-TUI task view) its output streams line
by line to EMIT so the view can show progress, and CANCEL aborts it.
@@ -509,6 +603,14 @@ def build_audiocpp(audiocpp_dir: Path, backend: str, *,
else:
argv += ["-Preset", f"windows-{backend}-release", "-Target",
"audiocpp_server", "-DeploymentBuild"]
+ elif sys.platform == "darwin" and not _metal_compiler_available():
+ say = emit if emit is not None else print
+ say("[INFO] Xcode's offline Metal compiler is not installed; "
+ "building with cmake directly (Apple Command Line Tools "
+ "suffice — Metal shaders are compiled by macOS at runtime on "
+ "first use, adding a short delay to the first model load; "
+ "install cmake, e.g. `brew install cmake`).")
+ argv = ["bash", "-c", _darwin_cmake_script()]
else:
argv = ["bash", str(script)]
if sys.platform != "darwin":
diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py
index 5d49fac..65b525b 100644
--- a/app/backends/audiocpp/catalog.py
+++ b/app/backends/audiocpp/catalog.py
@@ -82,7 +82,7 @@ def _backend_options(detected: Optional[str] = None
on macOS — never reach the build step.
"""
if sys.platform == "darwin":
- label = "cpu - Apple Metal (recorded as cpu)"
+ label = "cpu - Apple Metal"
if detected == "cpu":
label += " [auto-detected]"
return [(label, "cpu")], 0
diff --git a/app/backends/audiocpp/prebuilt.py b/app/backends/audiocpp/prebuilt.py
new file mode 100644
index 0000000..28d60b5
--- /dev/null
+++ b/app/backends/audiocpp/prebuilt.py
@@ -0,0 +1,506 @@
+"""Prebuilt audiocpp_server installs from audio.cpp's GitHub releases.
+
+Upstream (0xShug0/audio.cpp) publishes ready-to-run ``audiocpp_server``
+binaries for every release, built with ``AUDIOCPP_DEPLOYMENT_BUILD=ON``
+(the model-spec catalog is embedded, so the binary is self-contained):
+
+- ``audio-v<tag>-bin-macos-arm64-metal.tar.gz`` — Apple Silicon, real Metal
+- ``audio-v<tag>-bin-macos-x64-metal.tar.gz`` — Intel Macs (upstream's CI
+ Intel runners have no usable GPU, so this asset is CPU-only despite the
+ name; either way the tool records macOS installs as the ``cpu`` backend)
+- ``audio-v<tag>-bin-windows-x64-{cpu,vulkan,cuda12.4,cuda13.3}.zip`` —
+ Windows; the CUDA variants additionally need the matching
+ ``audio-v<tag>-cudart-windows-x64-cuda<ver>.zip`` runtime DLLs because
+ those builds ship the CUDA backend as ``ggml-cuda.dll``
+ (``ENGINE_ENABLE_CPU_ALL_VARIANTS``), loaded from the binary's directory.
+
+Installing a release into the same ``build/<dir>/bin/`` layout the source
+builds use means binary discovery (``find_audiocpp_server_bin`` /
+``built_server_binary``), the ``-metal-`` -> ``cpu`` backend token mapping,
+the managed-server launch, and the model-manager tooling from the git
+checkout all work unchanged. A ``prebuilt.json`` marker in the build
+directory records what was installed so ``build.update()`` can re-download
+a newer release instead of rebuilding from source.
+
+Everything downloads through urllib (no new dependencies); the GitHub API
+publishes a ``sha256:<hex>`` digest per asset, which is verified before
+extraction. Files saved by urllib carry no macOS quarantine attribute, so
+the ad-hoc-signed binaries run without Gatekeeper prompts.
+"""
+
+import hashlib
+import json
+import os
+import platform as _platform
+import re
+import shutil
+import sys
+import tarfile
+import tempfile
+import time
+import urllib.error
+import urllib.request
+import zipfile
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from backends import common
+
+RELEASES_API_URL = \
+ "https://api.github.com/repos/0xShug0/audio.cpp/releases/latest"
+
+# GitHub rejects API/HTTP requests without a User-Agent header.
+USER_AGENT = "tts-audiobook-generator (+https://github.com/0xShug0/audio.cpp)"
+
+API_TIMEOUT = 30
+
+# The CUDA runtime variant selected when the driver version cannot be
+# detected: CUDA 12.4 runs on the widest range of installed drivers.
+DEFAULT_CUDA_VARIANT = "12.4"
+
+# nvidia-smi driver major version from which the CUDA 13.x builds run.
+CUDA_13_MIN_DRIVER = 580
+
+_ASSET_RE = re.compile(
+ r"^audio-v[^/]+-bin-(?P<platform>macos|windows)-(?P<arch>arm64|x64)"
+ r"-(?P<variant>[a-z0-9.]+)\.(?P<ext>tar\.gz|zip)$")
+
+_CUDART_RE = re.compile(
+ r"^audio-v[^/]+-cudart-windows-x64-cuda(?P<variant>[a-z0-9.]+)\.zip$")
+
+# Download progress reporting: emit a line at most every 4 MiB.
+_REPORT_STEP = 4 * 1024 * 1024
+
+
+def prebuilt_supported(backend: Optional[str], *,
+ platform: Optional[str] = None) -> bool:
+ """Whether a prebuilt release asset can exist for PLATFORM/BACKEND.
+
+ Pure metadata (no network): macOS releases exist for the ``cpu``
+ backend only (Metal is recorded as ``cpu``), Windows releases exist
+ for ``cpu``, ``vulkan``, and ``cuda`` (HIP has no release asset and
+ keeps building from source), and no other platform ships prebuilt
+ binaries — Linux keeps using ``build_linux.sh``.
+ """
+ if platform is None:
+ platform = sys.platform
+ if platform == "darwin":
+ return backend == "cpu"
+ if platform == "win32":
+ return backend in ("cpu", "vulkan", "cuda")
+ return False
+
+
+def _arch_token(machine: Optional[str] = None) -> Optional[str]:
+ """The release asset's arch token for this machine, or None."""
+ if machine is None:
+ machine = _platform.machine()
+ machine = machine.lower()
+ if machine in ("arm64", "aarch64"):
+ return "arm64"
+ if machine in ("x86_64", "amd64"):
+ return "x64"
+ return None
+
+
+def _platform_token(platform: Optional[str] = None) -> Optional[str]:
+ if platform is None:
+ platform = sys.platform
+ if platform == "darwin":
+ return "macos"
+ if platform == "win32":
+ return "windows"
+ return None
+
+
+def _default_cuda_variant() -> str:
+ """The CUDA runtime variant to download, from the installed driver.
+
+ ``nvidia-smi`` reports the driver version; CUDA 13.x builds need a
+ 580+ driver, older (or undetectable) drivers get the CUDA 12.4 build.
+ """
+ try:
+ proc = common.run_console_subprocess_quiet(
+ ["nvidia-smi", "--query-gpu=driver_version",
+ "--format=csv,noheader"], timeout=10)
+ except Exception:
+ return DEFAULT_CUDA_VARIANT
+ if proc is None or proc.returncode != 0:
+ return DEFAULT_CUDA_VARIANT
+ for line in proc.stdout.decode("utf-8", errors="replace").splitlines():
+ major = line.strip().split(".")[0]
+ if major.isdigit():
+ return "13.3" if int(major) >= CUDA_13_MIN_DRIVER \
+ else DEFAULT_CUDA_VARIANT
+ return DEFAULT_CUDA_VARIANT
+
+
+def install_dir(audiocpp_dir: Path, backend: Optional[str], *,
+ platform: Optional[str] = None) -> Optional[Path]:
+ """The ``bin`` directory a prebuilt install for BACKEND lands in.
+
+ Mirrors the source-build directory names so backend detection and
+ binary discovery keep working: ``build/macos-metal-release/bin`` on
+ macOS and ``build/windows-<backend>-release/bin`` on Windows. None
+ when the platform/backend has no prebuilt asset (see
+ ``prebuilt_supported``).
+ """
+ token = _platform_token(platform)
+ if token == "macos":
+ if backend != "cpu":
+ return None
+ return audiocpp_dir / "build" / "macos-metal-release" / "bin"
+ if token == "windows":
+ if backend not in ("cpu", "vulkan", "cuda"):
+ return None
+ return (audiocpp_dir / "build"
+ / f"windows-{backend}-release" / "bin")
+ return None
+
+
+def marker_path(audiocpp_dir: Path, backend: Optional[str], *,
+ platform: Optional[str] = None) -> Optional[Path]:
+ """The ``prebuilt.json`` marker path for this install, or None."""
+ bin_dir = install_dir(audiocpp_dir, backend, platform=platform)
+ if bin_dir is None:
+ return None
+ return bin_dir.parent / "prebuilt.json"
+
+
+def installed_release(audiocpp_dir: Path, backend: Optional[str], *,
+ platform: Optional[str] = None) -> Optional[dict]:
+ """What prebuilt release is installed for BACKEND, or None.
+
+ Reads the ``prebuilt.json`` marker written by ``install_prebuilt``
+ (``{"tag", "asset", "sha256", "installed_at"}``). Any unreadable or
+ missing marker means "not a prebuilt install" — a source build lives
+ there instead and ``update()`` keeps rebuilding it.
+ """
+ path = marker_path(audiocpp_dir, backend, platform=platform)
+ if path is None or not path.is_file():
+ return None
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return None
+ if not isinstance(data, dict) or not data.get("tag"):
+ return None
+ return data
+
+
+def fetch_latest_release(*, timeout: int = API_TIMEOUT) -> Optional[dict]:
+ """The latest audio.cpp GitHub release (API JSON), or None.
+
+ Unauthenticated requests suffice (60/hour); callers treat None as
+ "cannot check right now" and keep whatever is installed.
+ """
+ request = urllib.request.Request(
+ RELEASES_API_URL,
+ headers={"User-Agent": USER_AGENT,
+ "Accept": "application/vnd.github+json"})
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except (OSError, ValueError) as exc:
+ print(f"[ERROR] Could not reach {RELEASES_API_URL}: {exc}")
+ return None
+
+
+def select_assets(assets: List[dict], backend: Optional[str], *,
+ platform: Optional[str] = None,
+ machine: Optional[str] = None,
+ cuda_variant: Optional[str] = None
+ ) -> Optional[Tuple[dict, Optional[dict]]]:
+ """Pick the release assets for this machine and BACKEND.
+
+ Returns ``(main, extra)`` — the server binary archive plus the CUDA
+ runtime DLL archive when BACKEND is ``cuda`` — or None when no asset
+ matches (unsupported platform, HIP, an unexpected arch). The CUDA
+ variant defaults to the driver-appropriate one (``_default_cuda_variant``).
+ """
+ token = _platform_token(platform)
+ arch = _arch_token(machine)
+ if token is None or arch is None or not prebuilt_supported(
+ backend, platform=platform):
+ return None
+ variant: Optional[str]
+ cuda: Optional[str] = None
+ if token == "macos":
+ variant = "metal"
+ elif backend == "cuda":
+ # The binary asset names carry a "cuda" prefix on the variant
+ # token (``...-bin-windows-x64-cuda12.4.zip``); the cudart asset
+ # names use the bare version (``...-cudart-...-cuda12.4.zip``).
+ cuda = cuda_variant or _default_cuda_variant()
+ variant = "cuda" + cuda
+ else:
+ variant = backend
+ main = _find_asset(assets, token, arch, variant)
+ if main is None:
+ return None
+ extra = None
+ if token == "windows" and backend == "cuda":
+ extra = _find_cudart_asset(assets, cuda or "")
+ if extra is None:
+ return None
+ return main, extra
+
+
+def _find_asset(assets: List[dict], platform_token: str, arch: str,
+ variant: str) -> Optional[dict]:
+ for asset in assets:
+ match = _ASSET_RE.match(str(asset.get("name", "")))
+ if match and match.group("platform") == platform_token \
+ and match.group("arch") == arch \
+ and match.group("variant") == variant:
+ return asset
+ return None
+
+
+def _find_cudart_asset(assets: List[dict], variant: str) -> Optional[dict]:
+ for asset in assets:
+ match = _CUDART_RE.match(str(asset.get("name", "")))
+ if match and match.group("variant") == variant:
+ return asset
+ return None
+
+
+def _download(url: str, dest: Path, *, emit=None, cancel=None) -> int:
+ """Stream URL to DEST with progress lines, honoring CANCEL.
+
+ Returns 0 on success, 130 when cancelled (the partial file is
+ removed), 1 on any network or filesystem error. EMIT receives a
+ progress line at most every 4 MiB (the in-TUI task view sink when
+ set; console paths print nothing until the install summary).
+ """
+ say = emit if emit is not None else print
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+ try:
+ with urllib.request.urlopen(request, timeout=API_TIMEOUT) as response:
+ total = response.headers.get("Content-Length")
+ total = int(total) if total else None
+ with dest.open("wb") as fh:
+ downloaded = 0
+ next_report = _REPORT_STEP
+ while True:
+ if common.cancel_requested(cancel):
+ fh.close()
+ dest.unlink(missing_ok=True)
+ say("[INFO] Download cancelled.")
+ return 130
+ chunk = response.read(1 << 20)
+ if not chunk:
+ break
+ fh.write(chunk)
+ downloaded += len(chunk)
+ if emit is not None and downloaded >= next_report:
+ next_report += _REPORT_STEP
+ if total:
+ say(f"[INFO] Downloading: "
+ f"{downloaded / (1 << 20):.1f} / "
+ f"{total / (1 << 20):.1f} MB "
+ f"({100 * downloaded // total}%)")
+ else:
+ say(f"[INFO] Downloading: "
+ f"{downloaded / (1 << 20):.1f} MB")
+ if emit is not None:
+ say(f"[INFO] Downloaded {dest.name} "
+ f"({dest.stat().st_size / (1 << 20):.1f} MB).")
+ return 0
+ except urllib.error.HTTPError as exc:
+ print(f"[ERROR] Download failed ({exc.code} {exc.reason}): {url}")
+ dest.unlink(missing_ok=True)
+ return 1
+ except OSError as exc:
+ print(f"[ERROR] Download failed: {exc}")
+ dest.unlink(missing_ok=True)
+ return 1
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as fh:
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _verify_checksum(path: Path, asset: dict, *, emit=None) -> bool:
+ """Verify PATH against the asset's published sha256 digest.
+
+ The GitHub API publishes ``digest: "sha256:<hex>"``; a missing digest
+ only warns (older releases), a mismatched one fails the install.
+ """
+ expected = str(asset.get("digest") or "")
+ if not expected.startswith("sha256:"):
+ print(f"[WARNING] {path.name} has no published checksum; "
+ "skipping verification")
+ return True
+ actual = _sha256(path)
+ if actual != expected[len("sha256:"):]:
+ (emit if emit is not None else print)(
+ f"[ERROR] {path.name}: checksum mismatch "
+ f"(expected {expected}, got sha256:{actual}); not installing "
+ "a tampered or corrupted download.")
+ return False
+ (emit if emit is not None else print)(
+ f"[OK] {path.name}: checksum verified.")
+ return True
+
+
+def _validate_member(name: str) -> str:
+ """Normalize an archive member name; raise ValueError on traversal."""
+ normalized = name.replace("\\", "/")
+ path = Path(normalized)
+ if path.is_absolute() or ".." in path.parts or path.drive:
+ raise ValueError(f"unsafe archive member: {name!r}")
+ return normalized
+
+
+def _extract(archive: Path, dest: Path, *, emit=None) -> None:
+ """Extract a release archive into DEST (tar.gz or zip, safely)."""
+ say = emit if emit is not None else print
+ say(f"[INFO] Extracting {archive.name} into {dest}...")
+ if tarfile.is_tarfile(archive):
+ with tarfile.open(archive) as tf:
+ try:
+ tf.extractall(dest, filter="data")
+ except TypeError: # Python < 3.12: no filter= parameter
+ for member in tf.getmembers():
+ _validate_member(member.name)
+ tf.extractall(dest)
+ return
+ with zipfile.ZipFile(archive) as zf:
+ for name in zf.namelist():
+ _validate_member(name)
+ zf.extractall(dest)
+
+
+def _sync_checkout_to_release(checkout: Path, tag: str, *,
+ emit=None, cancel=None) -> None:
+ """Point the checkout's tracked files at the installed release tag.
+
+ The prebuilt binary embeds the release tag's model-spec catalog; the
+ wizard reads model_specs/ and runs tools/model_manager_v2.py from the
+ checkout, so detaching to the same tag keeps the tooling, the model
+ catalog, and the binary consistent. Non-fatal: a failure (offline
+ fetch, local edits) leaves the checkout on its current commit — the
+ binary still works, worst case the model list drifts slightly from
+ what the binary knows.
+ """
+ fetch_rc = common.run_console_subprocess(
+ ["git", "-C", str(checkout), "fetch", "--tags", "origin"],
+ emit=emit, cancel=cancel)
+ if fetch_rc != 0:
+ print(f"[WARNING] Could not fetch audio.cpp tags (exit {fetch_rc}); "
+ f"the checkout stays on its current commit (binary is {tag}).")
+ return
+ detach_rc = common.run_console_subprocess(
+ ["git", "-C", str(checkout), "checkout", "--detach", tag],
+ emit=emit, cancel=cancel)
+ if detach_rc != 0:
+ print(f"[WARNING] Could not check out {tag} (exit {detach_rc}); "
+ "the checkout's model catalog may differ from the binary's.")
+
+
+def _ensure_executables(bin_dir: Path) -> None:
+ """Make the extracted binaries executable (POSIX only).
+
+ GitHub's artifact round-trip does not preserve tar permission bits,
+ so the released binaries can land mode 644; the hub launches
+ ``audiocpp_server`` directly, which then fails with EACCES. Every
+ extension-less ``audiocpp_*`` entry in the bin directory (server,
+ cli, gguf converter) gets the exec bits; data files (metallib,
+ model_specs/, tools/) are untouched.
+ """
+ if os.name != "posix":
+ return
+ for entry in bin_dir.iterdir():
+ if entry.is_file() and not entry.suffix \
+ and entry.name.startswith("audiocpp_"):
+ entry.chmod(entry.stat().st_mode | 0o111)
+
+
+def install_prebuilt(audiocpp_dir: Path, backend: Optional[str], *,
+ emit=None, cancel=None,
+ cuda_variant: Optional[str] = None) -> int:
+ """Download and install the latest prebuilt audiocpp_server for BACKEND.
+
+ Fetches the latest release, picks this machine's asset(s), downloads
+ them to a temp directory, verifies the published sha256 digests, then
+ extracts into ``build/<dir>/bin/`` (replacing any previous content —
+ including a source build there), syncs the checkout to the release
+ tag, and writes the ``prebuilt.json`` marker. Streaming output and
+ cancellation behave like the other install steps. Returns 0 on
+ success, 130 when cancelled, 1 on any failure.
+ """
+ say = emit if emit is not None else print
+ bin_dir = install_dir(audiocpp_dir, backend)
+ if bin_dir is None:
+ print(f"[ERROR] No prebuilt audiocpp_server asset exists for "
+ f"{backend!r} on this platform; build from source instead.")
+ return 1
+ say("[INFO] Checking the latest audio.cpp release...")
+ release = fetch_latest_release()
+ if release is None:
+ return 1
+ tag = str(release.get("tag_name") or "?")
+ pair = select_assets(release.get("assets") or [], backend,
+ cuda_variant=cuda_variant)
+ if pair is None:
+ print(f"[ERROR] No prebuilt audiocpp_server asset for "
+ f"{backend!r} in release {tag}; build from source instead.")
+ return 1
+ main, extra = pair
+ existing = installed_release(audiocpp_dir, backend)
+ if existing and existing.get("tag") == tag \
+ and existing.get("asset") == main.get("name"):
+ say(f"[OK] Prebuilt audiocpp_server is already {tag}.")
+ return 0
+
+ tmp = Path(tempfile.mkdtemp(prefix="audiocpp-prebuilt-"))
+ try:
+ downloads: List[Tuple[dict, Path]] = []
+ for asset in (main, extra):
+ if asset is None:
+ continue
+ dest = tmp / str(asset.get("name"))
+ say(f"[INFO] Downloading {asset.get('name')} "
+ f"({int(asset.get('size') or 0) / (1 << 20):.1f} MB) "
+ f"from release {tag}...")
+ rc = _download(str(asset.get("browser_download_url")), dest,
+ emit=emit, cancel=cancel)
+ if rc != 0:
+ return rc
+ downloads.append((asset, dest))
+ for asset, dest in downloads:
+ if not _verify_checksum(dest, asset, emit=emit):
+ return 1
+ if common.cancel_requested(cancel):
+ say("[INFO] Download cancelled.")
+ return 130
+ build_dir = bin_dir.parent
+ if build_dir.is_dir():
+ shutil.rmtree(build_dir, ignore_errors=True)
+ bin_dir.mkdir(parents=True, exist_ok=True)
+ for _asset, dest in downloads:
+ _extract(dest, bin_dir, emit=emit)
+ _ensure_executables(bin_dir)
+ marker = {
+ "tag": tag,
+ "asset": main.get("name"),
+ "sha256": str(main.get("digest") or ""),
+ "installed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ",
+ time.gmtime()),
+ }
+ (build_dir / "prebuilt.json").write_text(
+ json.dumps(marker, indent=2) + "\n", encoding="utf-8")
+ if sys.platform == "darwin" \
+ and _arch_token() == "x64":
+ say("[INFO] Note: upstream's Intel macOS release is built "
+ "without Metal (CI limitation); it runs on CPU.")
+ say(f"[OK] audiocpp_server {tag} installed at {bin_dir}.")
+ _sync_checkout_to_release(audiocpp_dir, tag, emit=emit, cancel=cancel)
+ return 0
+ finally:
+ shutil.rmtree(tmp, ignore_errors=True)
diff --git a/app/backends/audiocpp/wizard.py b/app/backends/audiocpp/wizard.py
index 6d2d33a..5b237c4 100644
--- a/app/backends/audiocpp/wizard.py
+++ b/app/backends/audiocpp/wizard.py
@@ -24,6 +24,7 @@ from ui import taskview, tui
from . import build as _build
from . import configsync as _configsync
from . import models as _models
+from . import prebuilt as _prebuilt
from . import voices as _voices
from .catalog import (_backend_options, build_model_entry,
build_server_config, detect_backend,
@@ -35,6 +36,22 @@ from .constants import (AUDIOCPP_DIR_NAME, AUDIOCPP_GIT_URL, BACKENDS,
_GO_BACK = object()
+def _flag_build_mode(args: argparse.Namespace, backend: str) -> str:
+ """Resolve the ``--prebuilt`` flag into a concrete build mode.
+
+ ``auto`` (the default) downloads the prebuilt release when this
+ platform/backend has one and otherwise builds from source; ``yes``
+ forces the download, ``no`` forces the source build. Only consulted
+ when a build/install is actually pending.
+ """
+ choice = getattr(args, "prebuilt", "auto")
+ if choice == "no":
+ return "source"
+ if choice == "yes" or _prebuilt.prebuilt_supported(backend):
+ return "prebuilt"
+ return "source"
+
+
class _GoBack(Exception):
"""Internal signal: Esc was pressed inside one of a screen's sub-prompts.
@@ -285,6 +302,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"port": port,
"backend": backend,
"build": build,
+ "build_mode": s.get("build_mode"),
"lazy_load": True,
"sync_port": None,
"wav_dir": s["wav_dir"],
@@ -351,9 +369,25 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
# the build row — honor that by re-checking at apply time.
if s["backend"] is None:
s["backend"] = result["backend"]
- s["build"] = bool(result.get("build")) and (
- _build.built_server_binary(s["audiocpp_dir"],
- s["backend"]) is None)
+ if _build.built_server_binary(s["audiocpp_dir"],
+ s["backend"]) is not None:
+ s["build"] = False
+ s["build_mode"] = None
+ else:
+ # The form reports every field's value, including hidden
+ # ones, so the mode is chosen by whether this backend has
+ # a prebuilt asset at all — not by which field was shown.
+ # Only the visible field's answer is meaningful: the
+ # choice field when a prebuilt asset exists, otherwise the
+ # plain build question.
+ if _prebuilt.prebuilt_supported(s["backend"]):
+ mode = result.get("build_mode")
+ if mode not in ("prebuilt", "source", "skip"):
+ mode = "prebuilt"
+ else:
+ mode = "source" if bool(result.get("build")) else "skip"
+ s["build_mode"] = mode
+ s["build"] = mode in ("prebuilt", "source")
# Clone-voice directory: only meaningful for clone-capable picks.
if args.input_dir is not None:
@@ -409,16 +443,21 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
if args.build_backend is not None:
s["backend"] = args.build_backend
s["build"] = s["detected_backend"] is None
+ s["build_mode"] = _flag_build_mode(args, s["backend"]) \
+ if s["build"] else None
elif args.backend is not None:
s["backend"] = args.backend
s["build"] = False
+ s["build_mode"] = None
elif s["detected_backend"] is not None:
# Already built: use the detected backend, no menu, no build.
s["backend"] = s["detected_backend"]
s["build"] = False
+ s["build_mode"] = None
else:
s["backend"] = None # decided by the form
s["build"] = None
+ s["build_mode"] = None
# Clone-voice directory seed: the voice_dir recorded by the
# server.json being modified, else the project voices/ dir (the
@@ -452,10 +491,30 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
"kind": "choice", "value": default_backend,
"choices": options,
})
+ # How to get the binary: macOS and Windows have upstream
+ # release binaries (no toolchain needed — see
+ # backends.audiocpp.prebuilt), everything else builds from
+ # source. HIP on Windows has no release asset, so it keeps
+ # the plain build question.
+ fields.append({
+ "key": "build_mode",
+ "label": "Get audiocpp_server",
+ "kind": "choice", "value": "prebuilt",
+ "choices": [
+ ("Download prebuilt server (recommended)", "prebuilt"),
+ ("Build from source", "source"),
+ ("Skip for now", "skip"),
+ ],
+ "visible": lambda fs: (
+ needs_build(fs) and _prebuilt.prebuilt_supported(
+ _field_val(fs, "backend", default_backend))),
+ })
fields.append({
"key": "build", "label": "Build audiocpp_server now?",
"kind": "bool", "value": True,
- "visible": needs_build,
+ "visible": lambda fs: (
+ needs_build(fs) and not _prebuilt.prebuilt_supported(
+ _field_val(fs, "backend", default_backend))),
})
wav_field = {
@@ -514,15 +573,15 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser
audiocpp_dir = _build.find_local_checkout()
if audiocpp_dir is None:
target = APP_DIR / AUDIOCPP_DIR_NAME
+ # The ggml build patches are deliberately NOT applied here: they
+ # belong to the source-build path (build_audiocpp applies them
+ # right before building), and a prebuilt install checks out the
+ # release tag, which the patches may not fit.
rc = taskview.run_steps(stdscr, "Clone audio.cpp", [
taskview.TaskStep(
f"Cloning audio.cpp into {target}",
lambda emit, cancel: common.git_clone(
AUDIOCPP_GIT_URL, target, emit=emit, cancel=cancel)),
- taskview.TaskStep(
- "Apply ggml build patches",
- lambda emit, cancel: _build.apply_ggml_patches(
- target, emit=emit, cancel=cancel)),
])
if rc == 130:
# Cancelled from the task view: abort the wizard quietly.
@@ -544,10 +603,12 @@ def _execute_lanes(settings: dict,
The same work ``_execute`` runs on the console, split into two lanes so
the view can run the build in one pane while configuring and downloading
- models in the other (both progress bars visible at once). The build lane
- exists only when ``settings["build"]`` is set; the models lane always
- exists (transcribe → write server.json → download/print commands).
- Shared results (the transcription mapping) travel through a small closure
+ models in the other (both progress bars visible at once). The install
+ lane exists only when ``settings["build"]`` is set: it downloads the
+ prebuilt release (``settings["build_mode"] == "prebuilt"``) or builds
+ from source, per the user's choice. The models lane always exists
+ (transcribe → write server.json → download/print commands). Shared
+ results (the transcription mapping) travel through a small closure
dict scoped to the models lane. Each step's ``work(emit, cancel)``
returns its exit code; subprocess steps stream through EMIT and abort on
CANCEL, while print()-based steps are captured by the view's stdout
@@ -559,25 +620,44 @@ def _execute_lanes(settings: dict,
lanes: List[taskview.TaskLane] = []
if build:
- def build_step(emit, cancel):
- rc = _build.build_audiocpp(audiocpp_dir, settings["backend"],
- emit=emit, cancel=cancel)
- if rc == 124:
- print("[WARNING] build went silent and was stopped; the "
- "server.json was still written — build "
- "audiocpp_server manually before starting it")
- elif rc != 0:
- print(f"[WARNING] build exited with code {rc}; the server.json "
- "was still written — build audiocpp_server manually "
- "before starting it")
- else:
- print("[OK] build complete")
- return rc
+ mode = settings.get("build_mode") or "source"
+ if mode == "prebuilt":
+ def build_step(emit, cancel):
+ rc = _prebuilt.install_prebuilt(
+ audiocpp_dir, settings["backend"],
+ emit=emit, cancel=cancel)
+ if rc == 130:
+ return rc
+ if rc != 0:
+ print(f"[WARNING] prebuilt download failed (exit {rc}); "
+ "the server.json was still written — build "
+ "audiocpp_server from source ('Build audio.cpp "
+ "Server', or re-run with --prebuilt no) before "
+ "starting it")
+ else:
+ print("[OK] prebuilt audiocpp_server installed")
+ return rc
+ build_title = (f"Download prebuilt audiocpp_server "
+ f"({settings['backend']})")
+ else:
+ def build_step(emit, cancel):
+ rc = _build.build_audiocpp(audiocpp_dir, settings["backend"],
+ emit=emit, cancel=cancel)
+ if rc == 124:
+ print("[WARNING] build went silent and was stopped; the "
+ "server.json was still written — build "
+ "audiocpp_server manually before starting it")
+ elif rc != 0:
+ print(f"[WARNING] build exited with code {rc}; the "
+ "server.json was still written — build "
+ "audiocpp_server manually before starting it")
+ else:
+ print("[OK] build complete")
+ return rc
+ build_title = f"Build audiocpp_server ({settings['backend']})"
lanes.append(taskview.TaskLane(
"Build",
- [taskview.TaskStep(
- f"Build audiocpp_server ({settings['backend']})",
- build_step)]))
+ [taskview.TaskStep(build_title, build_step)]))
def transcribe(emit, cancel):
args.input_dir = settings["wav_dir"]
@@ -703,18 +783,22 @@ def setup_screen(stdscr) -> int:
def build_screen(stdscr) -> int:
- """Build audiocpp_server from the hub when the checkout has no binary.
-
- Asks which backend to build for (pre-selecting the backend an existing
- server.json records, else cuda), runs the build inside the TUI task view
- — alongside a download of any missing models when server.json is already
- configured and those models map to an install command (the split view),
- or just the build otherwise — then updates server.json's ``backend``
- field to match. Returns 0 on success, non-zero when the user backed out,
- cancelled, or the build failed. This is the hub's "Build audio.cpp
- server" action, so a checkout that was cloned but never built is always
- buildable from the TUI; the standalone "Download Missing Models" action
- stays as the fallback when the download fails or is interrupted.
+ """Install audiocpp_server from the hub when the checkout has none.
+
+ Asks which backend to install for (pre-selecting the backend an
+ existing server.json records, else cuda), then — where a prebuilt
+ release asset exists (macOS and Windows, see
+ ``backends.audiocpp.prebuilt``) — whether to download it or build
+ from source. The chosen action runs inside the TUI task view —
+ alongside a download of any missing models when server.json is
+ already configured and those models map to an install command (the
+ split view), or alone otherwise — then updates server.json's
+ ``backend`` field to match. Returns 0 on success, non-zero when the
+ user backed out, cancelled, or the install failed. This is the hub's
+ "Build audio.cpp Server" action, so a checkout that was cloned but
+ never built is always installable from the TUI; the standalone
+ "Download Missing Models" action stays as the fallback when a model
+ download fails or is interrupted.
"""
checkout = _build.find_local_checkout()
if checkout is None:
@@ -736,12 +820,28 @@ def build_screen(stdscr) -> int:
if backend is _GO_BACK:
return 1
+ mode = "source"
+ if _prebuilt.prebuilt_supported(backend):
+ mode = tui.menu(
+ stdscr, "Install audiocpp_server:",
+ [("Download prebuilt server from GitHub releases "
+ "(recommended)", "prebuilt"),
+ ("Build from source", "source")],
+ default_index=0, back_value=_GO_BACK)
+ if mode is _GO_BACK:
+ return 1
+
def build_step(emit, cancel):
- return _build.build_audiocpp(checkout, backend, emit=emit, cancel=cancel)
+ if mode == "prebuilt":
+ return _prebuilt.install_prebuilt(checkout, backend,
+ emit=emit, cancel=cancel)
+ return _build.build_audiocpp(checkout, backend, emit=emit,
+ cancel=cancel)
- lanes = [taskview.TaskLane(
- "Build", [taskview.TaskStep(
- f"Build audiocpp_server ({backend})", build_step)])]
+ action = "Download prebuilt audiocpp_server" if mode == "prebuilt" \
+ else f"Build audiocpp_server ({backend})"
+ lanes = [taskview.TaskLane("Build", [taskview.TaskStep(action,
+ build_step)])]
# Missing models this build can also fetch, so a configured backend that
# lost its binary is restored to "installed" in one step.
@@ -758,13 +858,16 @@ def build_screen(stdscr) -> int:
"Download models",
[taskview.TaskStep("Download missing models", download_step)]))
- title = "Build & download models" if len(lanes) == 2 \
- else "Build audiocpp_server"
+ install_title = ("Download prebuilt audiocpp_server"
+ if mode == "prebuilt" else "Build audiocpp_server")
+ title = f"{install_title} & download models" if len(lanes) == 2 \
+ else install_title
rc = taskview.run_lanes(stdscr, title, lanes)
if rc != 0:
return rc
if not _configsync.update_server_backend(backend):
- tui.flash(stdscr, f"audiocpp_server built for {backend}. (Could not "
+ verb = "installed" if mode == "prebuilt" else "built"
+ tui.flash(stdscr, f"audiocpp_server {verb} for {backend}. (Could not "
"update server.json's backend field — reconfigure audio.cpp "
"if it was already configured.)", "warn")
# Models that can't be mapped to an install command still need hand
@@ -818,6 +921,8 @@ def _collect_from_flags(args: argparse.Namespace,
default-location fallback then also exists).
"""
# Checkout: ./app/audio.cpp, else --clone clones one there.
+ # (The ggml build patches are applied by build_audiocpp itself, so a
+ # prebuilt install never needs them.)
audiocpp_dir = _build.find_local_checkout()
if audiocpp_dir is None and args.clone:
target = APP_DIR / AUDIOCPP_DIR_NAME
@@ -825,13 +930,6 @@ def _collect_from_flags(args: argparse.Namespace,
if rc != 0:
parser.error(f"git clone failed (exit {rc}); clone audio.cpp "
f"manually: git clone {AUDIOCPP_GIT_URL} {target}")
- patch_rc = _build.apply_ggml_patches(target)
- if patch_rc != 0:
- parser.error(
- f"ggml build patches could not be applied to {target} "
- f"(exit {patch_rc}); see messages above. The audio.cpp "
- f"fork's vendored ggml may have changed — re-evaluate "
- f"app/backends/patches/.")
audiocpp_dir = target
if audiocpp_dir is None:
parser.error(
@@ -895,6 +993,10 @@ def _collect_from_flags(args: argparse.Namespace,
else:
backend = "cuda"
build = False
+ # How a pending install happens: the prebuilt release by default on
+ # macOS/Windows (``--prebuilt no`` forces the source build), always
+ # the source build elsewhere.
+ build_mode = _flag_build_mode(args, backend) if build else None
port = _configsync.config_port()
lazy_load = True
@@ -936,6 +1038,7 @@ def _collect_from_flags(args: argparse.Namespace,
"port": port,
"backend": backend,
"build": build,
+ "build_mode": build_mode,
"lazy_load": lazy_load,
"sync_port": None,
"wav_dir": wav_dir,
@@ -981,6 +1084,13 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--build-backend", choices=BACKENDS, default=None,
help="Build audiocpp_server for this backend when it "
"is not built yet, and use it in server.json")
+ parser.add_argument("--prebuilt", choices=("auto", "yes", "no"),
+ default="auto",
+ help="How to install audiocpp_server when it is "
+ "missing: auto downloads the prebuilt release "
+ "on macOS/Windows and builds from source "
+ "elsewhere; yes forces the prebuilt download; "
+ "no forces a source build")
parser.add_argument("--whisper-model", type=str, default="base",
help="Whisper model size for transcription "
"(default: base)")
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index 32c234e..00f3512 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -2,15 +2,17 @@
`--backend audiocpp` talks to `audiocpp_server` from [audio.cpp](https://github.com/0xShug0/audio.cpp), which hosts numerous TTS model families.
-The easiest way is the TUI: run `python audiobook.py`, choose **Configure Backends… → Install Backend → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Start/Stop Backend Servers** menu or automatically when converting). The setup asks exactly two screens: first the model tree, then one combined options form (like **Generate Audiobooks**) for everything else — the inference backend and whether to build it now, the voice-clone `.wav` directory and how to transcribe it, automatic model download, the default-model sync, and deleting models dropped on a re-run; rows that do not apply to your selection are hidden. The server always binds `127.0.0.1` on the port configured in `AUDIOCPP_API_URL` (edit it in **Settings**), so neither is ever asked. The clone, build, transcription and model downloads all run inside the TUI — each shows a status (and, where the tool can measure it, a progress bar), and can be cancelled — instead of dropping to console output. Run it directly with `python -m backends.audiocpp` from `app/` — flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding parts for scripting. The TUI runs in the managed `app/envs/tts` venv, which installs faster-whisper when wheels exist for your platform (it is tagged optional in `requirements.txt`: on platforms without compatible builds the setup skips it and voice-clone transcription degrades to manual transcripts). For a manual setup, make sure `whisper` or `faster_whisper` is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry.
+The easiest way is the TUI: run `python audiobook.py`, choose **Configure Backends… → Install Backend → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), installs `audiocpp_server` — by default downloading the prebuilt release binary on macOS and Windows (no compiler, no Xcode needed), or building from source elsewhere — lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Start/Stop Backend Servers** menu or automatically when converting). The setup asks exactly two screens: first the model tree, then one combined options form (like **Generate Audiobooks**) for everything else — the inference backend and how to get the server binary (prebuilt download, source build, or skip), the voice-clone `.wav` directory and how to transcribe it, automatic model download, the default-model sync, and deleting models dropped on a re-run; rows that do not apply to your selection are hidden. The server always binds `127.0.0.1` on the port configured in `AUDIOCPP_API_URL` (edit it in **Settings**), so neither is ever asked. The clone, install, transcription and model downloads all run inside the TUI — each shows a status (and, where the tool can measure it, a progress bar), and can be cancelled — instead of dropping to console output. Run it directly with `python -m backends.audiocpp` from `app/` — flags like `--wavs`, `--families`, `--build-backend`, `--prebuilt auto|yes|no`, `--clone` skip the corresponding parts for scripting. The TUI runs in the managed `app/envs/tts` venv, which installs faster-whisper when wheels exist for your platform (it is tagged optional in `requirements.txt`: on platforms without compatible builds the setup skips it and voice-clone transcription degrades to manual transcripts). For a manual setup, make sure `whisper` or `faster_whisper` is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry.
-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 built), `built (not configured)` (binary built, 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** builds it from the TUI (the wizard offers the build during setup too), so a backend whose build you skipped is never stuck as "unavailable". On a fresh install the setup is one continuous flow: clone → configure → and then the build and the model downloads run **simultaneously** in a split view (half building, half downloading). The setup steps are therefore ordered build > 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 build, and **Download Missing Models (audio.cpp)** remains only as a fallback for when a download fails or is interrupted.
+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.
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 and build audiocpp_server
+### Download or build audiocpp_server
-Download and build `audiocpp_server` 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). **Update Backends** keeps it 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:
```bash
# Linux
@@ -20,8 +22,16 @@ scripts/build_linux.sh --backend cuda --target audiocpp_server --deployment-buil
```
```bash
-# macOS (Metal is the only buildable backend there; the hub records it as "cpu")
-scripts/build_metal.sh --target audiocpp_server --deployment-build
+# macOS (Metal is the only buildable backend there; the hub records it as "cpu").
+# Needs full Xcode (build_metal.sh requires its offline Metal compiler). Without
+# Xcode, the hub builds with cmake directly — Apple's Command Line Tools
+# (clang) plus cmake suffice, because the Metal shaders are then compiled by
+# macOS itself at runtime on first use:
+cmake -S . -B build/macos-metal-release -DCMAKE_BUILD_TYPE=RelWithDebInfo \
+ -DENGINE_ENABLE_METAL=ON -DENGINE_ENABLE_CUDA=OFF -DENGINE_ENABLE_VULKAN=OFF \
+ -DENGINE_ENABLE_OPENMP=OFF -DGGML_METAL_EMBED_LIBRARY=ON \
+ -DAUDIOCPP_DEPLOYMENT_BUILD=ON
+cmake --build build/macos-metal-release --parallel $(sysctl -n hw.logicalcpu) --target audiocpp_server
```
```powershell
diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py
index 7f14b68..4c00001 100644
--- a/app/tests/test_backends_audiocpp.py
+++ b/app/tests/test_backends_audiocpp.py
@@ -1,14 +1,19 @@
"""Tests for the audio.cpp backend setup module (backends/audiocpp.py)."""
import argparse
+import hashlib
import io
import json
+import shutil
import sys
+import tarfile
import tempfile
import threading
import unittest
+import zipfile
from contextlib import redirect_stdout
from pathlib import Path
+from typing import Optional
from unittest.mock import MagicMock, patch
from converter import config
@@ -574,7 +579,7 @@ class BackendOptionsTests(unittest.TestCase):
with patch("sys.platform", "darwin"):
options, default_index = make_server.catalog._backend_options()
self.assertEqual(options,
- [("cpu - Apple Metal (recorded as cpu)", "cpu")])
+ [("cpu - Apple Metal", "cpu")])
self.assertEqual(default_index, 0)
def test_darwin_marks_detected_cpu(self):
@@ -1299,6 +1304,8 @@ class BuildAudiocppTests(unittest.TestCase):
def test_darwin_uses_metal_script_without_backend(self):
with patch.object(common, "run_console_subprocess",
return_value=0) as run, \
+ patch.object(make_server.build, "_metal_compiler_available",
+ return_value=True), \
patch("sys.platform", "darwin"):
rc = make_server.build.build_audiocpp(self.checkout, "cpu")
self.assertEqual(rc, 0)
@@ -1311,9 +1318,46 @@ class BuildAudiocppTests(unittest.TestCase):
self.assertIn("--deployment-build", argv)
self.assertEqual(run.call_args[1]["cwd"], self.checkout)
+ def test_darwin_without_metal_compiler_runs_cmake_directly(self):
+ # No Xcode (no offline Metal compiler): build_metal.sh would abort
+ # at its probe, so the build must fall back to a direct cmake
+ # invocation with the Metal shaders embedded as source — which
+ # needs only the Command Line Tools.
+ with patch.object(common, "run_console_subprocess",
+ return_value=0) as run, \
+ patch.object(make_server.build, "_metal_compiler_available",
+ return_value=False), \
+ patch("sys.platform", "darwin"):
+ rc = make_server.build.build_audiocpp(self.checkout, "cpu")
+ self.assertEqual(rc, 0)
+ argv = run.call_args[0][0]
+ self.assertEqual(argv[:2], ["bash", "-c"])
+ script = argv[2]
+ self.assertIn("cmake -S . -B build/macos-metal-release", script)
+ self.assertIn("-DENGINE_ENABLE_METAL=ON", script)
+ self.assertIn("-DGGML_METAL_EMBED_LIBRARY=ON", script)
+ self.assertIn("-DAUDIOCPP_DEPLOYMENT_BUILD=ON", script)
+ self.assertIn("cmake --build build/macos-metal-release", script)
+ self.assertIn("--target audiocpp_server", script)
+ self.assertNotIn("build_metal.sh", script)
+ self.assertEqual(run.call_args[1]["cwd"], self.checkout)
+
+ def test_metal_compiler_probe(self):
+ # The probe mirrors build_metal.sh's own check: a failing (or
+ # missing) xcrun metal means the offline compiler is absent.
+ with patch.object(common, "run_console_subprocess_quiet",
+ return_value=None):
+ self.assertFalse(
+ make_server.build._metal_compiler_available())
+ with patch.object(common, "run_console_subprocess_quiet",
+ return_value=MagicMock(returncode=0)):
+ self.assertTrue(make_server.build._metal_compiler_available())
+
def test_darwin_cuda_backend_gets_no_arch_flags(self):
with patch.object(common, "run_console_subprocess",
return_value=0) as run, \
+ patch.object(make_server.build, "_metal_compiler_available",
+ return_value=True), \
patch("sys.platform", "darwin"):
rc = make_server.build.build_audiocpp(self.checkout, "cuda")
self.assertEqual(rc, 0)
@@ -3087,3 +3131,478 @@ class LaunchHintTests(unittest.TestCase):
"-Target audiocpp_server -DeploymentBuild", out)
self.assertIn("windows-cpu-release", out)
self.assertIn("build_windows_hip.ps1", out)
+
+
+def _asset(name, size=1234, digest=None):
+ """A GitHub release asset entry shaped like the API returns."""
+ return {"name": name, "size": size, "digest": digest,
+ "browser_download_url": f"https://example.test/{name}"}
+
+
+def _release(assets, tag="v9.9.9"):
+ return {"tag_name": tag, "assets": assets}
+
+
+def _sha256_file(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+def _make_tar_gz(tmp: Path, name: str, entries: dict,
+ mode: int = 0o755) -> Path:
+ archive = tmp / name
+ with tarfile.open(archive, "w:gz") as tf:
+ for member, content in entries.items():
+ data = content.encode("utf-8")
+ info = tarfile.TarInfo(member)
+ info.size = len(data)
+ info.mode = mode
+ tf.addfile(info, io.BytesIO(data))
+ return archive
+
+
+def _make_zip(tmp: Path, name: str, entries: dict) -> Path:
+ archive = tmp / name
+ with zipfile.ZipFile(archive, "w") as zf:
+ for member, content in entries.items():
+ zf.writestr(member, content)
+ return archive
+
+
+class PrebuiltSelectTests(unittest.TestCase):
+ """Asset selection for the prebuilt release downloads."""
+
+ ASSETS = [
+ _asset("audio-v9.9.9-bin-macos-arm64-metal.tar.gz"),
+ _asset("audio-v9.9.9-bin-macos-x64-metal.tar.gz"),
+ _asset("audio-v9.9.9-bin-windows-x64-cpu.zip"),
+ _asset("audio-v9.9.9-bin-windows-x64-vulkan.zip"),
+ _asset("audio-v9.9.9-bin-windows-x64-cuda12.4.zip"),
+ _asset("audio-v9.9.9-bin-windows-x64-cuda13.3.zip"),
+ _asset("audio-v9.9.9-cudart-windows-x64-cuda12.4.zip"),
+ _asset("audio-v9.9.9-cudart-windows-x64-cuda13.3.zip"),
+ _asset("audio-v9.9.9-bin-ubuntu-x64-cpu.tar.gz"),
+ ]
+
+ def test_darwin_arm64_selects_the_metal_tarball(self):
+ pair = make_server.prebuilt.select_assets(
+ self.ASSETS, "cpu", platform="darwin", machine="arm64")
+ self.assertEqual(
+ pair[0]["name"], "audio-v9.9.9-bin-macos-arm64-metal.tar.gz")
+ self.assertIsNone(pair[1])
+
+ def test_darwin_x64_selects_its_own_tarball(self):
+ pair = make_server.prebuilt.select_assets(
+ self.ASSETS, "cpu", platform="darwin", machine="x86_64")
+ self.assertEqual(
+ pair[0]["name"], "audio-v9.9.9-bin-macos-x64-metal.tar.gz")
+
+ def test_darwin_has_no_cuda_asset(self):
+ self.assertIsNone(make_server.prebuilt.select_assets(
+ self.ASSETS, "cuda", platform="darwin", machine="arm64"))
+
+ def test_windows_selects_cpu_and_vulkan_zips(self):
+ for backend, name in (("cpu", "audio-v9.9.9-bin-windows-x64-cpu.zip"),
+ ("vulkan",
+ "audio-v9.9.9-bin-windows-x64-vulkan.zip")):
+ pair = make_server.prebuilt.select_assets(
+ self.ASSETS, backend, platform="win32", machine="AMD64")
+ self.assertEqual(pair[0]["name"], name)
+ self.assertIsNone(pair[1])
+
+ def test_windows_cuda_pairs_the_binary_with_its_cudart(self):
+ pair = make_server.prebuilt.select_assets(
+ self.ASSETS, "cuda", platform="win32", machine="AMD64",
+ cuda_variant="12.4")
+ self.assertEqual(pair[0]["name"],
+ "audio-v9.9.9-bin-windows-x64-cuda12.4.zip")
+ self.assertEqual(pair[1]["name"],
+ "audio-v9.9.9-cudart-windows-x64-cuda12.4.zip")
+
+ def test_windows_cuda_without_cudart_asset_is_rejected(self):
+ assets = [_asset("audio-v9.9.9-bin-windows-x64-cuda12.4.zip")]
+ self.assertIsNone(make_server.prebuilt.select_assets(
+ assets, "cuda", platform="win32", machine="AMD64",
+ cuda_variant="12.4"))
+
+ def test_windows_hip_has_no_asset(self):
+ self.assertIsNone(make_server.prebuilt.select_assets(
+ self.ASSETS, "hip", platform="win32", machine="AMD64"))
+
+ def test_windows_arm64_has_no_asset(self):
+ self.assertIsNone(make_server.prebuilt.select_assets(
+ self.ASSETS, "cpu", platform="win32", machine="arm64"))
+
+ def test_linux_never_selects(self):
+ self.assertIsNone(make_server.prebuilt.select_assets(
+ self.ASSETS, "cuda", platform="linux", machine="x86_64"))
+
+ def test_prebuilt_supported_matrix(self):
+ supported = make_server.prebuilt.prebuilt_supported
+ self.assertTrue(supported("cpu", platform="darwin"))
+ self.assertFalse(supported("cuda", platform="darwin"))
+ self.assertTrue(supported("cpu", platform="win32"))
+ self.assertTrue(supported("vulkan", platform="win32"))
+ self.assertTrue(supported("cuda", platform="win32"))
+ self.assertFalse(supported("hip", platform="win32"))
+ self.assertFalse(supported("cuda", platform="linux"))
+
+ def test_default_cuda_variant_follows_the_driver(self):
+ def driver(version):
+ proc = MagicMock()
+ proc.returncode = 0
+ proc.stdout = f"{version}\n".encode("utf-8")
+ return proc
+
+ with patch.object(make_server.prebuilt.common,
+ "run_console_subprocess_quiet",
+ return_value=driver("580.82.07")):
+ self.assertEqual(
+ make_server.prebuilt._default_cuda_variant(), "13.3")
+ with patch.object(make_server.prebuilt.common,
+ "run_console_subprocess_quiet",
+ return_value=driver("579.10")):
+ self.assertEqual(
+ make_server.prebuilt._default_cuda_variant(), "12.4")
+ with patch.object(make_server.prebuilt.common,
+ "run_console_subprocess_quiet",
+ return_value=None):
+ self.assertEqual(
+ make_server.prebuilt._default_cuda_variant(), "12.4")
+
+
+class PrebuiltInstallTests(unittest.TestCase):
+ """install_prebuilt: download, verify, extract, mark."""
+
+ def _checkout(self) -> Path:
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ return _make_checkout(Path(tmp.name))
+
+ def _marker(self, checkout: Path, backend: str) -> Path:
+ return make_server.prebuilt.marker_path(checkout, backend,
+ platform="darwin")
+
+ def _install_darwin(self, checkout: Path, *, digest: str,
+ asset_name="audio-v9.9.9-bin-macos-x64-metal.tar.gz",
+ archive=None):
+ if archive is None:
+ archive = _make_tar_gz(
+ Path(self.tmp.name), asset_name,
+ {"./audiocpp_server": "#!/bin/sh\n",
+ "./tools/model_manager_v2.py": "# tool\n"})
+ asset = _asset(asset_name, size=archive.stat().st_size,
+ digest=f"sha256:{digest}")
+ release = _release([asset])
+
+ def fake_download(url, dest, *, emit=None, cancel=None):
+ self.assertEqual(url, asset["browser_download_url"])
+ shutil.copyfile(archive, dest)
+ return 0
+
+ with patch("sys.platform", "darwin"), \
+ patch.object(make_server.prebuilt, "fetch_latest_release",
+ return_value=release), \
+ patch.object(make_server.prebuilt, "_download",
+ side_effect=fake_download) as mk_dl, \
+ patch.object(make_server.prebuilt.common,
+ "run_console_subprocess", return_value=0):
+ rc = make_server.prebuilt.install_prebuilt(checkout, "cpu")
+ return rc, mk_dl
+
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.tmp = tmp
+
+ def test_darwin_install_extracts_and_marks(self):
+ checkout = self._checkout()
+ archive = _make_tar_gz(
+ Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz",
+ {"./audiocpp_server": "#!/bin/sh\n",
+ "./tools/model_manager_v2.py": "# tool\n"}, mode=0o644)
+ rc, _ = self._install_darwin(
+ checkout, digest=_sha256_file(archive), archive=archive)
+ self.assertEqual(rc, 0)
+ bin_dir = checkout / "build" / "macos-metal-release" / "bin"
+ server = bin_dir / "audiocpp_server"
+ self.assertTrue(server.exists())
+ # GitHub's artifact round-trip loses the tar permission bits; the
+ # install must restore the exec bit the hub relies on to launch.
+ self.assertTrue(server.stat().st_mode & 0o111)
+ self.assertTrue((bin_dir / "tools" / "model_manager_v2.py").exists())
+ marker = json.loads(
+ (checkout / "build" / "macos-metal-release" / "prebuilt.json")
+ .read_text(encoding="utf-8"))
+ self.assertEqual(marker["tag"], "v9.9.9")
+ self.assertEqual(marker["asset"],
+ "audio-v9.9.9-bin-macos-x64-metal.tar.gz")
+
+ def test_install_replaces_a_previous_build_dir(self):
+ checkout = self._checkout()
+ stale = checkout / "build" / "macos-metal-release" / "bin"
+ stale.mkdir(parents=True)
+ (stale / "junk.txt").write_text("stale", encoding="utf-8")
+ archive = _make_tar_gz(
+ Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz",
+ {"./audiocpp_server": "#!/bin/sh\n"})
+ rc, _ = self._install_darwin(
+ checkout, digest=_sha256_file(archive), archive=archive)
+ self.assertEqual(rc, 0)
+ self.assertFalse((stale / "junk.txt").exists())
+ self.assertTrue((stale / "audiocpp_server").exists())
+
+ def test_reinstall_of_the_same_release_is_a_noop(self):
+ checkout = self._checkout()
+ archive = _make_tar_gz(
+ Path(self.tmp.name), "audio-v9.9.9-bin-macos-x64-metal.tar.gz",
+ {"./audiocpp_server": "#!/bin/sh\n"})
+ rc, _ = self._install_darwin(
+ checkout, digest=_sha256_file(archive), archive=archive)
+ self.assertEqual(rc, 0)
+ rc, mk_dl = self._install_darwin(
+ checkout, digest=_sha256_file(archive), archive=archive)
+ self.assertEqual(rc, 0)
+ mk_dl.assert_not_called()
+
+ def test_checksum_mismatch_aborts_without_installing(self):
+ checkout = self._checkout()
+ rc, _ = self._install_darwin(checkout,
+ digest="sha256:" + "0" * 64)
+ self.assertEqual(rc, 1)
+ self.assertFalse(
+ (checkout / "build" / "macos-metal-release" / "bin"
+ / "audiocpp_server").exists())
+ self.assertIsNone(
+ make_server.prebuilt.installed_release(checkout, "cpu"))
+
+ def test_windows_cuda_installs_both_zips_into_the_preset_dir(self):
+ checkout = self._checkout()
+ main = _make_zip(
+ Path(self.tmp.name), "audio-v9.9.9-bin-windows-x64-cuda13.3.zip",
+ {"audiocpp_server.exe": "MZ",
+ "ggml-cuda.dll": "MZ"})
+ cudart = _make_zip(
+ Path(self.tmp.name), "audio-v9.9.9-cudart-windows-x64-cuda13.3.zip",
+ {"cudart64_13.dll": "MZ"})
+ release = _release([
+ _asset(main.name, size=main.stat().st_size,
+ digest=f"sha256:{_sha256_file(main)}"),
+ _asset(cudart.name, size=cudart.stat().st_size,
+ digest=f"sha256:{_sha256_file(cudart)}"),
+ ])
+ archives = {f"https://example.test/{p.name}": p
+ for p in (main, cudart)}
+
+ def fake_download(url, dest, *, emit=None, cancel=None):
+ shutil.copyfile(archives[url], dest)
+ return 0
+
+ with patch("sys.platform", "win32"), \
+ patch.object(make_server.prebuilt, "fetch_latest_release",
+ return_value=release), \
+ patch.object(make_server.prebuilt, "_download",
+ side_effect=fake_download), \
+ patch.object(make_server.prebuilt.common,
+ "run_console_subprocess", return_value=0):
+ rc = make_server.prebuilt.install_prebuilt(
+ checkout, "cuda", cuda_variant="13.3")
+ self.assertEqual(rc, 0)
+ bin_dir = checkout / "build" / "windows-cuda-release" / "bin"
+ self.assertTrue((bin_dir / "audiocpp_server.exe").exists())
+ self.assertTrue((bin_dir / "cudart64_13.dll").exists())
+ self.assertEqual(make_server.prebuilt.installed_release(
+ checkout, "cuda", platform="win32")["tag"], "v9.9.9")
+
+ def test_unsupported_backend_fails_loudly(self):
+ checkout = self._checkout()
+ with patch("sys.platform", "linux"):
+ rc = make_server.prebuilt.install_prebuilt(checkout, "cuda")
+ self.assertEqual(rc, 1)
+
+ def test_marker_roundtrip_and_garbage(self):
+ checkout = self._checkout()
+ self.assertIsNone(make_server.prebuilt.installed_release(
+ checkout, "cpu", platform="darwin"))
+ marker = self._marker(checkout, "cpu")
+ marker.parent.mkdir(parents=True)
+ marker.write_text(json.dumps({"tag": "v1.2.3"}),
+ encoding="utf-8")
+ self.assertEqual(make_server.prebuilt.installed_release(
+ checkout, "cpu", platform="darwin")["tag"], "v1.2.3")
+ marker.write_text("not json", encoding="utf-8")
+ self.assertIsNone(make_server.prebuilt.installed_release(
+ checkout, "cpu", platform="darwin"))
+
+ def test_install_dir_mirrors_the_source_build_layout(self):
+ checkout = Path("/tmp/acpp")
+ self.assertEqual(
+ make_server.prebuilt.install_dir(checkout, "cpu",
+ platform="darwin"),
+ checkout / "build" / "macos-metal-release" / "bin")
+ self.assertEqual(
+ make_server.prebuilt.install_dir(checkout, "cuda",
+ platform="win32"),
+ checkout / "build" / "windows-cuda-release" / "bin")
+ self.assertIsNone(
+ make_server.prebuilt.install_dir(checkout, "hip",
+ platform="win32"))
+ self.assertIsNone(
+ make_server.prebuilt.install_dir(checkout, "cpu",
+ platform="linux"))
+
+
+class PrebuiltUpdateRoutingTests(unittest.TestCase):
+ """update() routes prebuilt installs to a release re-download."""
+
+ def _checkout(self) -> Path:
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ return _make_checkout(Path(tmp.name))
+
+ def _mark_prebuilt(self, checkout: Path, tag: str) -> None:
+ marker = make_server.prebuilt.marker_path(checkout, "cpu",
+ platform="darwin")
+ marker.parent.mkdir(parents=True)
+ (marker.parent / "bin").mkdir(exist_ok=True)
+ marker.write_text(json.dumps({"tag": tag, "asset": "x"}),
+ encoding="utf-8")
+
+ def _run(self, checkout: Path, *, marker: Optional[dict],
+ release: Optional[dict]):
+ with patch("sys.platform", "darwin"), \
+ patch.object(make_server.build, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(make_server.build.servers, "pid_for",
+ return_value=None), \
+ patch.object(make_server.build, "load_server_config",
+ return_value={"backend": "cpu"}), \
+ patch.object(make_server.prebuilt, "installed_release",
+ return_value=marker), \
+ patch.object(make_server.prebuilt, "fetch_latest_release",
+ return_value=release), \
+ patch.object(make_server.prebuilt, "install_prebuilt",
+ return_value=0) as mk_install, \
+ patch.object(common, "git_update",
+ return_value=0) as mk_git:
+ rc = make_server.build.update()
+ return rc, mk_install, mk_git
+
+ def test_newer_release_triggers_a_redownload(self):
+ checkout = self._checkout()
+ self._mark_prebuilt(checkout, "v0.6.0")
+ rc, mk_install, mk_git = self._run(
+ checkout, marker={"tag": "v0.6.0", "asset": "x"},
+ release=_release([], tag="v0.7.0"))
+ self.assertEqual(rc, 0)
+ mk_git.assert_not_called()
+ mk_install.assert_called_once_with(checkout, "cpu", emit=None,
+ cancel=None)
+
+ def test_current_release_is_a_noop(self):
+ checkout = self._checkout()
+ rc, mk_install, mk_git = self._run(
+ checkout, marker={"tag": "v0.7.0", "asset": "x"},
+ release=_release([], tag="v0.7.0"))
+ self.assertEqual(rc, 0)
+ mk_install.assert_not_called()
+ mk_git.assert_not_called()
+
+ def test_unreachable_github_keeps_the_install(self):
+ checkout = self._checkout()
+ rc, mk_install, mk_git = self._run(
+ checkout, marker={"tag": "v0.7.0", "asset": "x"},
+ release=None)
+ self.assertEqual(rc, 0)
+ mk_install.assert_not_called()
+ mk_git.assert_not_called()
+
+ def test_source_builds_still_route_through_git(self):
+ checkout = self._checkout()
+ (checkout / "server.json").write_text(
+ json.dumps({"models": [], "backend": "cpu"}),
+ encoding="utf-8")
+ with patch("sys.platform", "darwin"), \
+ patch.object(make_server.build, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(make_server.build.servers, "pid_for",
+ return_value=None), \
+ patch.object(make_server.prebuilt, "installed_release",
+ return_value=None), \
+ patch.object(common, "git_update",
+ return_value=0) as mk_git, \
+ patch.object(make_server.build, "build_audiocpp",
+ return_value=0) as mk_build:
+ rc = make_server.build.update()
+ self.assertEqual(rc, 0)
+ mk_git.assert_called_once()
+ mk_build.assert_called_once_with(checkout, "cpu", emit=None,
+ cancel=None)
+
+
+class PrebuiltFlagTests(unittest.TestCase):
+ """--prebuilt resolves into a concrete install mode."""
+
+ def _args(self, value):
+ return make_server.wizard.build_parser().parse_args(
+ ["--prebuilt", value] if value else [])
+
+ def test_auto_downloads_on_macos_and_windows(self):
+ with patch("sys.platform", "darwin"):
+ self.assertEqual(
+ make_server.wizard._flag_build_mode(self._args("auto"),
+ "cpu"), "prebuilt")
+ with patch("sys.platform", "win32"):
+ self.assertEqual(
+ make_server.wizard._flag_build_mode(self._args("auto"),
+ "cuda"), "prebuilt")
+ self.assertEqual(
+ make_server.wizard._flag_build_mode(self._args("auto"),
+ "hip"), "source")
+
+ def test_auto_builds_from_source_on_linux(self):
+ self.assertEqual(
+ make_server.wizard._flag_build_mode(self._args("auto"),
+ "cuda"), "source")
+
+ def test_yes_and_no_force_their_modes(self):
+ self.assertEqual(
+ make_server.wizard._flag_build_mode(self._args("yes"), "hip"),
+ "prebuilt")
+ self.assertEqual(
+ make_server.wizard._flag_build_mode(self._args("no"), "cpu"),
+ "source")
+
+ def test_darwin_wizard_honors_the_form_mode(self):
+ # On macOS the combined form asks "Get audiocpp_server" as a
+ # three-way choice; the answer must drive the install mode.
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ checkout = _make_checkout(Path(tmp.name))
+ catalog = make_server.catalog.load_model_catalog(checkout)
+ qwen_index = next(i for i, entry in enumerate(catalog)
+ if entry["family"] == "qwen3_tts")
+
+ def fake_tree(*args, **kwargs):
+ return [(qwen_index, "Qwen3-TTS-12Hz-1.7B-Base-GGUF")]
+
+ def fake_form(stdscr, title, fields, **kwargs):
+ by_key = {f["key"]: f for f in fields}
+ return {f["key"]: f["value"] for f in fields} | {
+ "backend": by_key["backend"]["value"],
+ "build_mode": mode,
+ }
+
+ for mode, expected_build in (("skip", False), ("source", True),
+ ("prebuilt", True)):
+ with patch("sys.platform", "darwin"), \
+ patch.object(make_server.build, "find_local_checkout",
+ return_value=checkout), \
+ patch.object(tui, "checkbox_tree",
+ side_effect=fake_tree), \
+ patch.object(tui, "form", side_effect=fake_form):
+ settings = make_server.wizard._wizard(
+ None, make_server.wizard.build_parser().parse_args([]),
+ make_server.wizard.build_parser())
+ self.assertIsNotNone(settings)
+ self.assertEqual(settings["build_mode"], mode)
+ self.assertEqual(settings["build"], expected_build)