aboutsummaryrefslogtreecommitdiff
path: root/app/backends/audiocpp/wizard.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-28 18:18:47 -0400
committerhistoria <historiavg@proton.me>2026-08-28 18:18:47 -0400
commit1487796d74a7b171f2c53c4212e446759d3a4bec (patch)
treebe0546fe36404a7bc6b98c1a472af574f4b28868 /app/backends/audiocpp/wizard.py
parente66eb0e7d4342ae1c58e9bbd341843753be548f0 (diff)
downloadtts-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/backends/audiocpp/wizard.py')
-rw-r--r--app/backends/audiocpp/wizard.py220
1 files changed, 165 insertions, 55 deletions
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)")