From 8b5c8697740ff415cf7f1d03c9fb5a8c8851d420 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 02:25:55 -0400 Subject: refactor: audiocpp.py setup flow --- app/backends/audiocpp/build.py | 339 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 339 insertions(+) create mode 100644 app/backends/audiocpp/build.py (limited to 'app/backends/audiocpp/build.py') diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py new file mode 100644 index 0000000..a4b307a --- /dev/null +++ b/app/backends/audiocpp/build.py @@ -0,0 +1,339 @@ +"""Checkout lifecycle: clone location, ggml patches, binary build, uninstall.""" + +import contextlib +import io +import re +import shlex +import shutil +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from backends import common, servers +from backends.common import APP_DIR +from .catalog import _BACKEND_TOKEN_RE +from .constants import ( + AUDIOCPP_DIR_NAME, + AUDIOCPP_GIT_URL, + 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 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) -> Optional[Path]: + """Return the audio.cpp build helper script to run, or None. + + Prefers ``scripts/build_linux.sh``; otherwise the first + ``scripts/build_*.sh`` it finds. (Windows ``.bat`` scripts are not run + automatically — build manually there.) + """ + scripts = audiocpp_dir / "scripts" + if not scripts.is_dir(): + return None + 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)", + }, +] + + +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 build_audiocpp(audiocpp_dir: Path, backend: str, *, + emit=None, cancel=None) -> int: + """Build audiocpp_server for BACKEND, streaming output. + + 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) + 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 + argv = ["sh", str(script), "--backend", backend, "--target", + "audiocpp_server", "--deployment-build"] + 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 = common.LOG_DIR / ( + f"audiocpp_build_{datetime.now():%Y%m%d_%H%M%S}.log") + log_path.parent.mkdir(parents=True, exist_ok=True) + log_handle = log_path.open("w", encoding="utf-8") + + def tee(line: str) -> None: + log_handle.write(line + "\n") + log_handle.flush() + 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) + if rc != 0 and (cancel is None or not cancel.is_set()): + notice = (f"[ERROR] audio.cpp build failed (exit code {rc}).\n" + f" Build log: {log_path}\n" + f" Troubleshoot by re-running this command:\n" + f" {command}") + 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. + """ + 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: + print(f" sh {script} --backend " + "--target audiocpp_server --deployment-build") + print(f" then run: cd {audiocpp_dir} && ./build/-" + f"-release/bin/audiocpp_server --config {output_path}") + + -- cgit v1.2.3