"""Checkout lifecycle: clone location, ggml patches, binary build, uninstall.""" import contextlib import io import os import re import shlex import shutil import sys from pathlib import Path from typing import List, Optional 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 def uninstall(*, emit=None, cancel=None) -> int: """Remove the audio.cpp backend entirely: stop its server, delete the checkout. The checkout (``app/audio.cpp``) holds the built binary, the downloaded models, and the server.json, so removing the directory uninstalls the backend. A running server this tool started is stopped first (best-effort). EMIT is accepted for registry symmetry with the other backends but is unused here — this uninstall has no subprocess phase, and its prints are captured by the task view when run in the TUI. CANCEL is a ``threading.Event`` honored between phases only (after the server has been stopped, before the checkout is deleted), so a started phase always completes and the uninstall never tears halfway. Returns the exit code (130 when cancelled before a remaining phase). """ # Only stop when a pid file exists: without one this tool never # started the server, so the "not started by this tool" notice would # be uninstall-time noise. if servers.pid_for("audiocpp") is not None: servers.stop("audiocpp") if common.cancel_requested(cancel): return 130 checkout = find_local_checkout() if checkout is None: print("[INFO] No audio.cpp checkout to remove.") return 0 print(f"[INFO] Removing audio.cpp checkout {checkout}...") shutil.rmtree(checkout, ignore_errors=True) print("[OK] audio.cpp removed.") return 0 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. 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``) — and when that re-download fails (rate limit, offline), the same source-build route a source-built checkout uses runs instead, so 'Update Backends' still gets current by whatever means work; the installed binary is only replaced on success and keeps working meanwhile. Source-built checkouts keep the original flow — phases: stop server / git update / rebuild — CANCEL is honored between phases only, so a started phase always completes. The git update is a fetch plus hard reset to origin's 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 (nothing was ever built) the update stops after the checkout refresh — 'Build audio.cpp Server' handles a first build. The rebuild itself runs when the sources changed (HEAD moved) or the on-disk binary is missing or older than HEAD's commit time — the latter heals an interrupted (cancelled or failed) earlier rebuild, which leaves the previous binary in place against already-updated sources. An up-to-date checkout with a fresh binary costs one fetch. Returns the exit code (130 when cancelled before a remaining phase, or when a prebuilt re-download was cancelled). """ # Only stop when a pid file exists: without one this tool never # started the server, so the "not started by this tool" notice would # be uninstall-time noise. if servers.pid_for("audiocpp") is not None: servers.stop("audiocpp") if common.cancel_requested(cancel): return 130 checkout = find_local_checkout() if checkout is None: print("[INFO] No audio.cpp checkout to update.") return 0 backend = _rebuild_backend(checkout) fell_back = False if _prebuilt.installed_release(checkout, backend) is not None: rc = _update_prebuilt(checkout, backend, emit=emit, cancel=cancel) if rc == 0 or rc == 130: return rc # The re-download failed (the usual cause is GitHub's API rate # limit): the installed binary was left in place and keeps # working — recover in place like the install paths do, by # building from source instead. print(f"[WARNING] prebuilt update failed (exit {rc}); the installed " "audiocpp_server was left in place — falling back to a " "source build...") fell_back = True head_before = common.git_head(checkout) rc = common.git_update(checkout, emit=emit, cancel=cancel) if rc != 0: print(f"[WARNING] checkout update failed (exit {rc}); update " f"manually: git -C {checkout} pull") return rc head_after = common.git_head(checkout) if common.cancel_requested(cancel): return 130 if backend is None: print("[INFO] audiocpp_server was never built for a known " "backend; skipping the rebuild. 'Build audio.cpp Server' " "builds one.") return 0 binary = built_server_binary(checkout, backend) if not _rebuild_needed(checkout, binary, moved=head_after not in (None, head_before)): print(f"[OK] {checkout} is already at origin's HEAD with an " "up-to-date audiocpp_server.") if fell_back: _drop_prebuilt_marker(checkout, backend) return 0 if head_after in (None, head_before): print(f"[INFO] audiocpp_server on disk is older than the " f"checked-out sources (earlier build interrupted?); " f"rebuilding for {backend}.") else: print(f"[OK] Updated {checkout} to {head_after[:12]}; rebuilding " f"audiocpp_server for {backend}.") build_rc = build_audiocpp(checkout, backend, emit=emit, cancel=cancel) if build_rc != 0: print(f"[WARNING] rebuild exited with code {build_rc}; see the " "messages above (the build log under app/logs/ has the " "full output). The binary on disk is now older than the " "checked-out sources; re-running 'Update Backends' will " "retry the rebuild.") else: print("[OK] rebuild complete.") if fell_back: _drop_prebuilt_marker(checkout, backend) return build_rc def _drop_prebuilt_marker(checkout: Path, backend: Optional[str]) -> None: """Remove the prebuilt.json marker after a fallback source build. The fallback replaced (or matched) the downloaded binary with a source build, so the marker's "this build directory holds release " claim is stale — keeping it would make the next update re-download over the freshly built binary. Best-effort: a marker that cannot be removed only means the next update re-downloads. """ marker = _prebuilt.marker_path(checkout, backend) if marker is None: return try: marker.unlink(missing_ok=True) except OSError: pass def _update_prebuilt(checkout: Path, backend: Optional[str], *, emit=None, cancel=None) -> int: """The update route for a prebuilt (release-downloaded) install. Source builds update by rebuilding after a git pull; a prebuilt install instead re-downloads when upstream published a newer release. The current-version check resolves the newest tag from the release page's redirect (no API quota); the API is only consulted by the re-download itself, for the checksum digests. The recorded/detected backend selects the asset, mirroring what was originally installed. A GitHub outage is not fatal — the installed binary keeps working. Returns 0 when already current or when the check could not run; a failed re-download returns its exit code and ``update()`` falls back to a source build (a cancelled download, 130, aborts without falling back). """ marker = _prebuilt.installed_release(checkout, backend) tag = _prebuilt.resolve_latest_tag() if tag is None: release = _prebuilt.fetch_latest_release() tag = str(release.get("tag_name") or "") if release else "" if not tag: 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 if marker 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}.") return rc def _rebuild_needed(checkout: Path, binary: Optional[Path], *, moved: bool) -> bool: """True when audiocpp_server must be (re)built after an update. True when the checkout moved, the binary is missing, its age cannot be compared (no commit time), or it predates HEAD's commit — the last case is what a cancelled or failed earlier rebuild leaves behind (old binary, already-updated sources). """ if moved or binary is None: return True commit_time = common.git_commit_time(checkout) if commit_time is None: return True try: return binary.stat().st_mtime <= commit_time except OSError: return True def _rebuild_backend(checkout: Path) -> Optional[str]: """The inference backend to rebuild for after an update, or None. server.json's recorded backend wins (it is what the managed server launches); an existing build directory's token is the fallback for a checkout that was built but never configured. None means neither names a valid backend — there is no binary to keep fresh. """ server_config = load_server_config(checkout / "server.json") or {} recorded = server_config.get("backend") if recorded in BACKENDS: return recorded return detect_backend(checkout) def find_local_checkout() -> Optional[Path]: """Return the managed audio.cpp checkout at ``app/audio.cpp``. Returns the path only when it contains a ``model_specs`` directory; the checkout is installed there by the setup wizard and nowhere else. """ try: resolved = (APP_DIR / AUDIOCPP_DIR_NAME).resolve() except OSError: return None if (resolved / "model_specs").is_dir(): return resolved return None def find_audiocpp_server_bin(audiocpp_dir: Path) -> Optional[Path]: """Return the built audiocpp_server binary, or None when not built. Scans ``audiocpp_dir/build/*`` for a build directory containing ``bin/audiocpp_server`` (``.exe`` allowed on Windows). When several builds exist the first (alphabetical) is returned. """ build_root = audiocpp_dir / "build" if not build_root.is_dir(): return None try: build_dirs = sorted(build_root.iterdir(), key=lambda p: p.name.lower()) except OSError: return None for build_dir in build_dirs: if not build_dir.is_dir(): continue for name in ("audiocpp_server", "audiocpp_server.exe"): server = build_dir / "bin" / name if server.exists(): return server return None def built_server_binary(audiocpp_dir: Path, backend: str) -> Optional[Path]: """Return the built audiocpp_server for BACKEND, or None. Like ``find_audiocpp_server_bin`` but limited to build directories whose name carries the BACKEND token (``-cuda-``, ``-vulkan-``, ``-hip-``, ``-cpu-``; ``-metal-`` counts as ``cpu``). A checkout with builds for several backends is asked which one to use without re-offering a build for a backend that is already built. """ build_root = audiocpp_dir / "build" if not build_root.is_dir(): return None try: build_dirs = sorted(build_root.iterdir(), key=lambda p: p.name.lower()) except OSError: return None for build_dir in build_dirs: if not build_dir.is_dir(): continue match = _BACKEND_TOKEN_RE.search(build_dir.name.lower()) if not match: continue token = "cpu" if match.group(1) == "metal" else match.group(1) if token != backend: continue for name in ("audiocpp_server", "audiocpp_server.exe"): server = build_dir / "bin" / name if server.exists(): return server return None def find_build_script(audiocpp_dir: Path, backend: Optional[str] = None) -> Optional[Path]: """Return the audio.cpp build helper script to run, or None. Platform-aware, because audio.cpp ships one helper per platform: macOS builds through ``scripts/build_metal.sh`` (Metal is the only buildable inference backend there, recorded as ``cpu``), Windows through ``scripts/build_windows.ps1`` — or ``scripts/build_windows_hip.ps1`` when BACKEND is ``hip`` — and every other platform through ``scripts/build_linux.sh``. The first ``scripts/build_*.sh`` found is the fallback for the shell-script platforms, so a fork that renamed the helper still builds. Running the Windows scripts is supported: they are driven through ``powershell.exe`` (stock Windows PowerShell 5.1, present on every Windows 10/11 install) with ``-ExecutionPolicy Bypass``. BACKEND is only consulted on Windows (HIP uses its own script); on other platforms it is ignored. """ scripts = audiocpp_dir / "scripts" if not scripts.is_dir(): return None if sys.platform == "win32": name = ("build_windows_hip.ps1" if backend == "hip" else "build_windows.ps1") candidate = scripts / name return candidate if candidate.exists() else None if sys.platform == "darwin": preferred = scripts / "build_metal.sh" else: preferred = scripts / "build_linux.sh" if preferred.exists(): return preferred try: candidates = sorted(scripts.glob("build_*.sh"), key=lambda p: p.name.lower()) except OSError: return None return candidates[0] if candidates else None GGML_PATCHES = [ { "file": "ggml-top-k-cuda-iterator.patch", "target": "external/ggml/src/ggml-cuda/top-k.cu", "marker": r"#\s*include\s*", "label": "top-k.cu: add #include (CCCL 3.x build fix)", }, ] # No-output watchdog for the build: ninja prints a line per completed # compile, so 15 minutes of silence means a compiler job wedged (ptxas # hangs are the known failure mode of a buggy CUDA toolkit). The runner # kills the build and reports exit 124 (see run_console_subprocess). BUILD_STALL_TIMEOUT = 900 # Env override for the CUDA architectures passed to build_linux.sh # (--cuda-arch): a ';'-or-comma separated list of compute capabilities, # e.g. "86" or "86;89". Set it when GPU detection cannot run. CUDA_ARCH_ENV = "AUDIOCPP_CUDA_ARCH" # Compute capability -> common GPUs, shown when detection is impossible # and in ptxas failure guidance. Terse on purpose; one line. CUDA_ARCH_GUIDE = ("61 GTX 10xx/P40; 75 RTX 20xx; 80 A100; 86 RTX 30xx " "(3090)/A6000; 89 RTX 40xx (4090)/L40S; 90 H100; " "120/121 RTX 50xx (5090)/B200") def detect_cuda_arch() -> Optional[str]: """The CUDA architecture token to build for, or None when unknown. ``AUDIOCPP_CUDA_ARCH`` wins verbatim (validated as a ';'-or-comma separated list of compute capabilities like ``86`` or ``86;89``), so a user can pin the arch on machines where detection cannot run. Otherwise ``nvidia-smi`` reports each GPU's compute capability (works on Linux and Windows; it does not exist on macOS, where the CUDA backend is not a choice anyway): ``8.6`` becomes ``86``, several distinct GPUs join as ``86;89``. audio.cpp's CMake upgrades bare new architectures (``120``) to their suffixed forms (``120a``) itself. """ override = os.environ.get(CUDA_ARCH_ENV, "").strip() if override: parts = [part.strip() for part in override.replace(",", ";").split(";") if part.strip()] if parts and all(re.fullmatch(r"\d+(-real|-virtual)?", part) for part in parts): return ";".join(parts) print(f"[WARNING] {CUDA_ARCH_ENV}={override!r} is not an arch list " "(e.g. \"86\" or \"86;89\"); ignoring it") proc = common.run_console_subprocess_quiet( ["nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader"], timeout=10) if proc is None or proc.returncode != 0: return None arches: List[str] = [] for line in proc.stdout.decode("utf-8", errors="replace").splitlines(): cap = line.strip() if not re.fullmatch(r"\d+\.\d+", cap): continue arch = cap.replace(".", "") if arch not in arches: arches.append(arch) return ";".join(arches) if arches else None def _cuda_arch_argv(backend: str, emit=None) -> List[str]: """The CUDA-architecture flags for a CUDA build, plus a status line. EMIT is the in-TUI line sink when building from the task view (the line lands in the view, not the real terminal behind curses); without it the line prints to the console. Detection failure is not an error: the build then uses audio.cpp's portable default arch list, which is slower to compile but runs on any GPU. The flag name follows the platform's helper script: ``--cuda-arch`` for ``build_linux.sh``, ``-CudaArchitectures`` for ``build_windows.ps1`` (both accept a ';'-separated compute-capability list verbatim). macOS builds are Metal-only — ``build_metal.sh`` takes no arch flags — so a stray CUDA backend there gets no flags. """ if backend != "cuda" or sys.platform == "darwin": return [] arch = detect_cuda_arch() say = emit if emit is not None else print if arch is None: say(f"[INFO] CUDA architecture: portable default list (could not " f"detect a GPU; set {CUDA_ARCH_ENV}= to build only for " "this machine's GPU — much faster)") return [] flag = "-CudaArchitectures" if sys.platform == "win32" else "--cuda-arch" say(f"[INFO] CUDA architecture: {arch} (detected via nvidia-smi; " f"override with {CUDA_ARCH_ENV})") return [flag, arch] def _ptxas_failure_hint(log_path: Path) -> str: """Guidance appended when the build log shows a ptxas failure. ``ptxas fatal`` / ``nvcc error`` lines mean the CUDA toolkit's assembler (or nvcc itself) failed — an internal compiler error is a toolkit bug, not a broken checkout, and newer ggml template code on newer toolkit releases trips it. Building only for the local GPU's architecture skips most of the codegen paths ptxas chokes on, so the hint points at ``AUDIOCPP_CUDA_ARCH`` (with the detected arch, or the GPU table when detection cannot run); a different toolkit version is the remaining fix when narrowing the arch is not enough. """ try: text = log_path.read_text(encoding="utf-8", errors="ignore") except OSError: return "" if "ptxas fatal" not in text and "nvcc error" not in text: return "" arch = detect_cuda_arch() lines = [ " ptxas (the CUDA toolkit's GPU assembler) failed — with an " "internal compiler error this is a CUDA toolkit bug, not your " "sources.", f" Rebuild for this machine's GPU only: set {CUDA_ARCH_ENV}= " "(semicolon-separated for several GPUs) and re-run the build.", ] if arch: lines.append(f" Detected arch for this machine: {arch}") else: lines.append(f" Arch per GPU: {CUDA_ARCH_GUIDE}") lines.append(" If narrowing the arch still fails, a different CUDA " "toolkit version usually does (ptxas bugs are fixed in " "toolkit updates).") return "\n".join(lines) def apply_ggml_patches(audiocpp_dir: Path, *, emit=None, cancel=None) -> int: """Apply the shipped ggml build patches to an audio.cpp checkout. Idempotent: a patch whose marker already matches its target is skipped (it is either already applied, or the fork re-vendored a fixed ggml). A patch that no longer applies because the vendored file changed shape is a loud, non-interactive failure — the build is aborted so the user re-evaluates the patch instead of hitting a known nvcc break minutes later. Returns 0 when every patch is applied or already present, 1 on drift, 130 when cancelled. """ for patch in GGML_PATCHES: if cancel is not None and cancel.is_set(): return 130 target = audiocpp_dir / patch["target"] if not target.is_file(): print(f"[INFO] {patch['file']}: target {patch['target']} not " f"present in this checkout; skipping") continue try: text = target.read_text(encoding="utf-8", errors="ignore") except OSError as exc: print(f"[WARNING] {patch['file']}: could not read {target}: " f"{exc}; skipping") continue if re.search(patch["marker"], text): print(f"[OK] {patch['file']}: fix already present, skipping") continue patch_path = PATCH_DIR / patch["file"] if not patch_path.is_file(): print(f"[ERROR] {patch['file']}: patch file not found at " f"{patch_path}; cannot apply") return 1 check_argv = ["git", "-C", str(audiocpp_dir), "apply", "--check", "--whitespace=nowarn", str(patch_path)] check_rc = common.run_console_subprocess( check_argv, emit=emit, cancel=cancel) if check_rc == 130 or (cancel is not None and cancel.is_set()): return 130 if check_rc != 0: print(f"[ERROR] {patch['file']}: no longer applies to " f"{patch['target']} (git apply --check exit {check_rc}). " f"The audio.cpp fork's vendored ggml changed shape and " f"still lacks the fix. Re-evaluate {patch_path}: " f"regenerate the patch, or drop this entry if the fork " f"now ships the fix.") return 1 apply_argv = ["git", "-C", str(audiocpp_dir), "apply", "--whitespace=nowarn", str(patch_path)] rc = common.run_console_subprocess( apply_argv, emit=emit, cancel=cancel) if rc == 130 or (cancel is not None and cancel.is_set()): return 130 if rc != 0: print(f"[ERROR] {patch['file']}: git apply failed (exit {rc})") return rc print(f"[OK] {patch['file']}: applied ({patch['label']})") 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. The platform's own helper script runs the build (see ``find_build_script``): ``build_metal.sh`` under bash on macOS (Metal is the only backend there; it takes no ``--backend`` flag, so BACKEND only records what server.json names), ``build_windows.ps1`` — or ``build_windows_hip.ps1`` for hip — under stock ``powershell.exe`` on 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. On the EMIT (TUI) path the build output is also tee'd to ``app/logs/audiocpp_build_.log`` so it survives the curses session; when the build fails (and was not cancelled) a post-TUI notice with the copy-pastable command and the log path is queued for the console (see ``backends.common.record_post_tui_notice``). Returns the build script's exit code (non-zero when the script is missing). """ script = find_build_script(audiocpp_dir, backend) if script is None: message = (f"[ERROR] No build script found in {audiocpp_dir}/scripts; " "build audiocpp_server manually (see the audio.cpp README)") print(message) if emit is not None: common.record_post_tui_notice(message) return 1 if sys.platform == "win32": argv = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)] if backend == "hip": # The HIP helper's default build dir (build/hip) carries no # "-hip-" token, so detect_backend/built_server_binary would # never find the binary; name the dir after the preset instead. argv += ["-Target", "audiocpp_server", "-DeploymentBuild", "-BuildDir", "build/windows-hip-release"] 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": argv += ["--backend", backend] argv += ["--target", "audiocpp_server", "--deployment-build"] argv += _cuda_arch_argv(backend, emit=emit) command = f"cd {audiocpp_dir} && {shlex.join(argv)}" if emit is None: print(f"[INFO] Building audiocpp_server for {backend} ({command})...") patch_rc = apply_ggml_patches(audiocpp_dir, cancel=cancel) if patch_rc == 130 or (cancel is not None and cancel.is_set()): return 130 if patch_rc != 0: print("[ERROR] ggml build patches could not be applied; " "aborting audiocpp_server build. See the messages above " "and re-evaluate app/backends/patches/.") return patch_rc return common.run_console_subprocess(argv, cwd=audiocpp_dir) return _build_audiocpp_tui(emit, cancel, argv, command, audiocpp_dir) def _build_audiocpp_tui(emit, cancel, argv: List[str], command: str, audiocpp_dir: Path) -> int: """Run the build on the TUI path: tee output to a log file. The ggml patch step runs first, inside the same log: every emitted line (patch status, build output) is also written (and flushed) to ``app/logs/audiocpp_build_.log``. On failure a summary (the copy-pastable COMMAND and the log path) is emitted into the TUI, written to the log, and queued as a post-TUI console notice. A cancelled build (CANCEL set) is not reported as a failure, but its partial output stays in the log file. """ log_path, log_handle = logging_kit.run_artifact("audiocpp_build", log_dir=common.LOG_DIR) def tee(line: str) -> None: logging_kit.write_line(log_handle, line) emit(line) class _TeeWriter(io.TextIOBase): """Route print() output from the patch step into the log too.""" def write(self, s: str) -> int: for line in s.splitlines(): if line: tee(line) return len(s) try: with contextlib.redirect_stdout(_TeeWriter()): patch_rc = apply_ggml_patches(audiocpp_dir, emit=tee, cancel=cancel) if patch_rc == 130 or (cancel is not None and cancel.is_set()): return 130 if patch_rc != 0: notice = ("[ERROR] ggml build patches could not be applied; " "aborting audiocpp_server build. See the messages " "above and re-evaluate app/backends/patches/.") tee(notice) common.record_post_tui_notice(notice) return patch_rc tee(f"[INFO] Building audiocpp_server ({command})...") rc = common.run_console_subprocess( argv, cwd=audiocpp_dir, emit=tee, cancel=cancel, stall_timeout=BUILD_STALL_TIMEOUT) if rc != 0 and (cancel is None or not cancel.is_set()): if rc == 124: head = (f"[ERROR] audio.cpp build stalled — no output for " f"{BUILD_STALL_TIMEOUT // 60} minutes, so it was " "stopped (a wedged compiler job; often a ptxas " "hang from a buggy CUDA toolkit).") else: head = f"[ERROR] audio.cpp build failed (exit code {rc})." notice = (f"{head}\n" f" Build log: {log_path}\n" f" Troubleshoot by re-running this command:\n" f" {command}") ptxas_hint = _ptxas_failure_hint(log_path) if ptxas_hint: notice += "\n" + ptxas_hint for line in notice.splitlines(): tee(line) common.record_post_tui_notice(notice) finally: log_handle.close() return rc def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None: """Print remediation when audiocpp_server is missing (troubleshooting). The hub starts and stops the server itself, so a working install gets no manual launch instructions. When no binary was built, though, the user needs to know how to build and run it by hand. The commands are prefixed with ``cd &&`` because the server discovers model_specs/.json relative to its working directory, and the build command mirrors what ``build_audiocpp`` would run on this platform. """ if find_audiocpp_server_bin(audiocpp_dir) is not None: return print("\n[INFO] audiocpp_server binary not found. Build it first, e.g.:") script = find_build_script(audiocpp_dir) if script is not None: if sys.platform == "win32": print(f" powershell -NoProfile -ExecutionPolicy Bypass -File " f"{script} -Preset windows-cuda-release -Target " "audiocpp_server -DeploymentBuild") print(" presets: windows-cpu-release, windows-vulkan-release, " "windows-cuda-release (HIP: scripts/build_windows_hip.ps1)") elif sys.platform == "darwin": print(f" bash {script} --target audiocpp_server " "--deployment-build") else: print(f" bash {script} --backend " "--target audiocpp_server --deployment-build") print(f" then run: cd {audiocpp_dir} && ./build/-" f"-release/bin/audiocpp_server --config {output_path}")