From 270fa60c01866c4431d540be960b6cd2bc2b9c44 Mon Sep 17 00:00:00 2001 From: historia Date: Fri, 28 Aug 2026 16:52:14 -0400 Subject: feat: run auto-build audio.cpp scripts for macos and windows --- app/backends/audiocpp/build.py | 91 +++++++++++++--- app/backends/audiocpp/catalog.py | 14 ++- app/docs/backend-audiocpp.md | 15 ++- app/tests/test_backends_audiocpp.py | 203 +++++++++++++++++++++++++++++++++++- 4 files changed, 304 insertions(+), 19 deletions(-) (limited to 'app') diff --git a/app/backends/audiocpp/build.py b/app/backends/audiocpp/build.py index 1ad6018..22d2fa1 100644 --- a/app/backends/audiocpp/build.py +++ b/app/backends/audiocpp/build.py @@ -6,6 +6,7 @@ import os import re import shlex import shutil +import sys from pathlib import Path from typing import List, Optional @@ -232,17 +233,37 @@ def built_server_binary(audiocpp_dir: Path, backend: str) -> Optional[Path]: return None -def find_build_script(audiocpp_dir: Path) -> Optional[Path]: +def find_build_script(audiocpp_dir: Path, + backend: Optional[str] = None) -> 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.) + 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 - preferred = scripts / "build_linux.sh" + 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: @@ -318,15 +339,21 @@ def detect_cuda_arch() -> Optional[str]: def _cuda_arch_argv(backend: str, emit=None) -> List[str]: - """The ``--cuda-arch`` flags for a CUDA build, plus a status line. + """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": + if backend != "cuda" or sys.platform == "darwin": return [] arch = detect_cuda_arch() say = emit if emit is not None else print @@ -335,9 +362,10 @@ def _cuda_arch_argv(backend: str, emit=None) -> List[str]: 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 ["--cuda-arch", arch] + return [flag, arch] def _ptxas_failure_hint(log_path: Path) -> str: @@ -440,6 +468,14 @@ 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. + 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. @@ -453,7 +489,7 @@ def build_audiocpp(audiocpp_dir: Path, backend: str, *, Returns the build script's exit code (non-zero when the script is missing). """ - script = find_build_script(audiocpp_dir) + 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)") @@ -461,8 +497,23 @@ def build_audiocpp(audiocpp_dir: Path, backend: str, *, if emit is not None: common.record_post_tui_notice(message) return 1 - argv = ["sh", str(script), "--backend", backend, "--target", - "audiocpp_server", "--deployment-build"] + 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"] + 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: @@ -554,15 +605,27 @@ def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None: 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. + 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: - print(f" sh {script} --backend " - "--target audiocpp_server --deployment-build") + 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}") diff --git a/app/backends/audiocpp/catalog.py b/app/backends/audiocpp/catalog.py index 412fdf1..5d49fac 100644 --- a/app/backends/audiocpp/catalog.py +++ b/app/backends/audiocpp/catalog.py @@ -2,6 +2,7 @@ import json import re +import sys from pathlib import Path from typing import Dict, List, Optional, Set, Tuple @@ -73,9 +74,20 @@ def _backend_options(detected: Optional[str] = None options, that option gets ``[auto-detected]`` appended and is the default (cursor/start) selection; otherwise the first option is the default as before. Returns (options, default_index). + + On macOS only ``cpu`` is offered: Metal (via ``build_metal.sh``) is + the only buildable inference backend there, and it is recorded as + ``cpu`` (see the ``-metal-`` -> ``cpu`` mapping in ``detect_backend`` + and ``built_server_binary``), so cuda/vulkan/hip — which cannot build + on macOS — never reach the build step. """ + if sys.platform == "darwin": + label = "cpu - Apple Metal (recorded as cpu)" + if detected == "cpu": + label += " [auto-detected]" + return [(label, "cpu")], 0 width = max(len(name) for name, _ in _BACKEND_DESCRIPTIONS) - options: List[Tuple[str, str]] = [] + options = [] default_index = 0 for index, (name, desc) in enumerate(_BACKEND_DESCRIPTIONS): label = f"{name.ljust(width)} - {desc}" diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index 03e781e..32c234e 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -10,14 +10,27 @@ If you prefer to install the backend yourself (in your own environment, not the ### Download and 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. I'm using one of the helper scripts: +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: ```bash +# Linux git clone https://github.com/0xShug0/audio.cpp cd audio.cpp scripts/build_linux.sh --backend cuda --target audiocpp_server --deployment-build ``` +```bash +# macOS (Metal is the only buildable backend there; the hub records it as "cpu") +scripts/build_metal.sh --target audiocpp_server --deployment-build +``` + +```powershell +# Windows (needs VS Build Tools with the C++ workload; CUDA preset also needs +# the CUDA Toolkit, Vulkan preset the Vulkan SDK — the script names what's missing) +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\build_windows.ps1 -Preset windows-cuda-release -Target audiocpp_server -DeploymentBuild +# presets: windows-cpu-release, windows-vulkan-release, windows-cuda-release (HIP: scripts\build_windows_hip.ps1) +``` + `--deployment-build` compiles the `model_specs/` catalog into the binary so every family resolves its model contract even when the server is started outside the checkout (GGUF packages whose embedded spec is legacy, such as today's Qwen3-TTS ones, otherwise need `model_specs/.json` found relative to the working directory). The macOS script takes the same flag; the Windows PowerShell scripts take `-DeploymentBuild`. ### Install models diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index 82d6b88..7f14b68 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -570,6 +570,31 @@ class BackendOptionsTests(unittest.TestCase): self.assertEqual([value for _, value in options], list(make_server.BACKENDS)) + def test_darwin_offers_only_cpu(self): + with patch("sys.platform", "darwin"): + options, default_index = make_server.catalog._backend_options() + self.assertEqual(options, + [("cpu - Apple Metal (recorded as cpu)", "cpu")]) + self.assertEqual(default_index, 0) + + def test_darwin_marks_detected_cpu(self): + with patch("sys.platform", "darwin"): + options, default_index = make_server.catalog._backend_options( + "cpu") + self.assertTrue(options[default_index][0].endswith("[auto-detected]")) + self.assertEqual(options[default_index][1], "cpu") + + def test_windows_build_dir_names_map_to_backends(self): + for name, backend in (("windows-cuda-release", "cuda"), + ("windows-vulkan-release", "vulkan"), + ("windows-cpu-release", "cpu"), + ("windows-hip-release", "hip"), + ("macos-metal-release", "cpu")): + match = make_server.catalog._BACKEND_TOKEN_RE.search(name) + self.assertIsNotNone(match, name) + token = "cpu" if match.group(1) == "metal" else match.group(1) + self.assertEqual(token, backend, name) + class BuildServerConfigTests(unittest.TestCase): def test_single_entry_without_voice_dir(self): @@ -1234,6 +1259,12 @@ class BuildAudiocppTests(unittest.TestCase): self.scripts.mkdir() (self.scripts / "build_linux.sh").write_text("#!/bin/sh\n", encoding="utf-8") + (self.scripts / "build_metal.sh").write_text("#!/bin/bash\n", + encoding="utf-8") + (self.scripts / "build_windows.ps1").write_text("param()\n", + encoding="utf-8") + (self.scripts / "build_windows_hip.ps1").write_text("param()\n", + encoding="utf-8") self.log_dir = Path(self._td.name) / "logs" self.addCleanup(common.drain_post_tui_notices) @@ -1257,14 +1288,84 @@ class BuildAudiocppTests(unittest.TestCase): rc = make_server.build.build_audiocpp(self.checkout, "cuda") self.assertEqual(rc, 0) argv = run.call_args[0][0] - self.assertEqual(argv[:3], ["sh", str(self.scripts / "build_linux.sh"), - "--backend"]) + self.assertEqual(argv[:3], ["bash", str(self.scripts / "build_linux.sh"), + "--backend"]) self.assertIn("cuda", argv) self.assertIn("--target", argv) self.assertIn("audiocpp_server", argv) self.assertIn("--deployment-build", argv) self.assertEqual(run.call_args[1]["cwd"], self.checkout) + def test_darwin_uses_metal_script_without_backend(self): + with patch.object(common, "run_console_subprocess", + return_value=0) as run, \ + 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", str(self.scripts / "build_metal.sh")]) + self.assertNotIn("--backend", argv) + self.assertIn("--target", argv) + self.assertIn("audiocpp_server", argv) + self.assertIn("--deployment-build", argv) + self.assertEqual(run.call_args[1]["cwd"], self.checkout) + + def test_darwin_cuda_backend_gets_no_arch_flags(self): + with patch.object(common, "run_console_subprocess", + return_value=0) as run, \ + patch("sys.platform", "darwin"): + rc = make_server.build.build_audiocpp(self.checkout, "cuda") + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertNotIn("--backend", argv) + self.assertNotIn("--cuda-arch", argv) + + def test_windows_uses_powershell_with_preset(self): + with patch.object(common, "run_console_subprocess", + return_value=0) as run, \ + patch("sys.platform", "win32"): + rc = make_server.build.build_audiocpp(self.checkout, "vulkan") + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertEqual( + argv[:6], ["powershell", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-File"]) + self.assertEqual(argv[6], str(self.scripts / "build_windows.ps1")) + self.assertIn("-Preset", argv) + self.assertIn("windows-vulkan-release", argv) + self.assertIn("-Target", argv) + self.assertIn("audiocpp_server", argv) + self.assertIn("-DeploymentBuild", argv) + self.assertNotIn("-CudaArchitectures", argv) + self.assertEqual(run.call_args[1]["cwd"], self.checkout) + + def test_windows_cuda_arch_uses_powershell_flag(self): + with patch.object(common, "run_console_subprocess", + return_value=0) as run, \ + patch("sys.platform", "win32"), \ + patch.object(make_server.build, "detect_cuda_arch", + return_value="86;89"): + rc = make_server.build.build_audiocpp(self.checkout, "cuda") + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertIn("windows-cuda-release", argv) + self.assertEqual(argv[-2:], ["-CudaArchitectures", "86;89"]) + + def test_windows_hip_uses_hip_script_and_token_dir(self): + with patch.object(common, "run_console_subprocess", + return_value=0) as run, \ + patch("sys.platform", "win32"): + rc = make_server.build.build_audiocpp(self.checkout, "hip") + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertEqual(argv[6], + str(self.scripts / "build_windows_hip.ps1")) + self.assertNotIn("-Preset", argv) + self.assertIn("-BuildDir", argv) + self.assertIn("build/windows-hip-release", argv) + self.assertIn("-DeploymentBuild", argv) + def test_missing_script_returns_nonzero(self): for f in self.scripts.iterdir(): f.unlink() @@ -1312,7 +1413,7 @@ class BuildAudiocppTests(unittest.TestCase): notice = notices[0] self.assertIn("failed (exit code 3)", notice) self.assertIn(f"Build log: {logs[0]}", notice) - command = (f"cd {self.checkout} && sh " + command = (f"cd {self.checkout} && bash " f"{self.scripts / 'build_linux.sh'} --backend cuda " "--target audiocpp_server --deployment-build") self.assertIn(command, notice) @@ -1425,6 +1526,66 @@ class BuildAudiocppTests(unittest.TestCase): self.assertNotIn("AUDIOCPP_CUDA_ARCH", notice) +class FindBuildScriptTests(unittest.TestCase): + """Per-platform helper script selection (find_build_script).""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.addCleanup(self._td.cleanup) + self.checkout = Path(self._td.name) / "audio.cpp" + self.scripts = self.checkout / "scripts" + self.scripts.mkdir(parents=True) + + def _write(self, name): + (self.scripts / name).write_text("#!/bin/sh\n", encoding="utf-8") + + def test_linux_prefers_build_linux(self): + for name in ("build_linux.sh", "build_metal.sh"): + self._write(name) + with patch("sys.platform", "linux"): + self.assertEqual( + make_server.build.find_build_script(self.checkout), + self.scripts / "build_linux.sh") + + def test_darwin_prefers_build_metal(self): + for name in ("build_linux.sh", "build_metal.sh"): + self._write(name) + with patch("sys.platform", "darwin"): + self.assertEqual( + make_server.build.find_build_script(self.checkout), + self.scripts / "build_metal.sh") + + def test_darwin_falls_back_to_first_sh(self): + self._write("build_linux.sh") + with patch("sys.platform", "darwin"): + self.assertEqual( + make_server.build.find_build_script(self.checkout), + self.scripts / "build_linux.sh") + + def test_windows_selects_powershell_scripts(self): + self._write("build_windows.ps1") + self._write("build_windows_hip.ps1") + self._write("build_linux.sh") + with patch("sys.platform", "win32"): + find = make_server.build.find_build_script + self.assertEqual(find(self.checkout), + self.scripts / "build_windows.ps1") + self.assertEqual(find(self.checkout, "cuda"), + self.scripts / "build_windows.ps1") + self.assertEqual(find(self.checkout, "cpu"), + self.scripts / "build_windows.ps1") + self.assertEqual(find(self.checkout, "hip"), + self.scripts / "build_windows_hip.ps1") + + def test_windows_without_scripts_returns_none(self): + self._write("build_linux.sh") + with patch("sys.platform", "win32"): + self.assertIsNone(make_server.build.find_build_script(self.checkout)) + + def test_missing_scripts_dir_returns_none(self): + self.assertIsNone(make_server.build.find_build_script(self.checkout)) + + class DetectCudaArchTests(unittest.TestCase): """detect_cuda_arch: env override, nvidia-smi probe, None fallbacks.""" @@ -2890,3 +3051,39 @@ class LaunchHintTests(unittest.TestCase): out = self._capture(Path("/tmp/acpp"), Path("/tmp/acpp/server.json")) self.assertIn("Build it first", out) self.assertNotIn("Start the server with:", out) + + def _checkout_with_script(self, script_name): + td = tempfile.TemporaryDirectory() + self.addCleanup(td.cleanup) + checkout = Path(td.name) + scripts = checkout / "scripts" + scripts.mkdir() + (scripts / script_name).write_text("#!/bin/sh\n", encoding="utf-8") + return checkout + + def test_linux_hint_uses_bash_and_backend_flag(self): + checkout = self._checkout_with_script("build_linux.sh") + script = checkout / "scripts" / "build_linux.sh" + out = self._capture(checkout, checkout / "server.json") + self.assertIn(f"bash {script} --backend " + "--target audiocpp_server --deployment-build", out) + + def test_darwin_hint_uses_metal_without_backend(self): + checkout = self._checkout_with_script("build_metal.sh") + script = checkout / "scripts" / "build_metal.sh" + with patch("sys.platform", "darwin"): + out = self._capture(checkout, checkout / "server.json") + self.assertIn(f"bash {script} --target audiocpp_server " + "--deployment-build", out) + self.assertNotIn("--backend", out) + + def test_windows_hint_names_powershell_and_presets(self): + checkout = self._checkout_with_script("build_windows.ps1") + script = checkout / "scripts" / "build_windows.ps1" + with patch("sys.platform", "win32"): + out = self._capture(checkout, checkout / "server.json") + self.assertIn(f"powershell -NoProfile -ExecutionPolicy Bypass " + f"-File {script} -Preset windows-cuda-release " + "-Target audiocpp_server -DeploymentBuild", out) + self.assertIn("windows-cpu-release", out) + self.assertIn("build_windows_hip.ps1", out) -- cgit v1.2.3