diff options
| author | historia <historiavg@proton.me> | 2026-08-28 18:18:47 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-28 18:18:47 -0400 |
| commit | 1487796d74a7b171f2c53c4212e446759d3a4bec (patch) | |
| tree | be0546fe36404a7bc6b98c1a472af574f4b28868 /app | |
| parent | e66eb0e7d4342ae1c58e9bbd341843753be548f0 (diff) | |
| download | tts-audiobook-generator-1487796d74a7b171f2c53c4212e446759d3a4bec.tar.gz | |
feat: offer to download prebuilt mac/win binaries, new macos build fallback if xcode is not installed
Diffstat (limited to 'app')
| -rw-r--r-- | app/backends/audiocpp/__init__.py | 9 | ||||
| -rw-r--r-- | app/backends/audiocpp/build.py | 120 | ||||
| -rw-r--r-- | app/backends/audiocpp/catalog.py | 2 | ||||
| -rw-r--r-- | app/backends/audiocpp/prebuilt.py | 506 | ||||
| -rw-r--r-- | app/backends/audiocpp/wizard.py | 220 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 22 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 521 |
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) |
