diff options
| author | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
| commit | fe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch) | |
| tree | 9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/backends/audiocpp.py | |
| parent | f4b1de303704e13818259d5057d176cd841b6ed8 (diff) | |
| download | tts-audiobook-generator-fe4b2b9eb7fb8aac81f65630720c9079d0a3121a.tar.gz | |
feat: user-friendly menu gating, clearer install/configure path for backends
Diffstat (limited to 'app/backends/audiocpp.py')
| -rwxr-xr-x | app/backends/audiocpp.py | 522 |
1 files changed, 390 insertions, 132 deletions
diff --git a/app/backends/audiocpp.py b/app/backends/audiocpp.py index d4c79b0..7a8eb75 100755 --- a/app/backends/audiocpp.py +++ b/app/backends/audiocpp.py @@ -20,7 +20,7 @@ Usage: [--audiocpp-dir PATH] [--clone] [--families FAM1,FAM2] [--all-packages] [--host HOST] [--port PORT] [--build-backend {cuda,vulkan,hip,cpu}] [--backend {cuda,vulkan,hip,cpu}] - [--lazy-load] [--whisper-model NAME] [--force] + [--whisper-model NAME] [--force] [--download] [--no-sync-port] [--no-sync-model-ids] With no flags and a terminal, the TUI wizard runs. Without a terminal @@ -28,8 +28,8 @@ With no flags and a terminal, the TUI wizard runs. Without a terminal any missing required value is a hard error with a remediation hint. When the target ``server.json`` already exists, the TUI wizard runs as a -"modify": it loads the existing models, host, port, backend, lazy-load -and voice directory and pre-fills the screens with them (the model tree +"modify": it loads the existing models, host, port, backend and voice +directory and pre-fills the screens with them (the model tree opens with the installed models already checked) instead of prompting to overwrite, and offers to delete already-downloaded models that are no longer selected. @@ -39,11 +39,13 @@ import argparse import json import os import re +import shlex import shutil -import subprocess import sys +import tempfile import urllib.parse import urllib.request +from datetime import datetime from pathlib import Path from typing import Callable, Dict, List, Optional, Set, Tuple @@ -79,7 +81,7 @@ from backends.common import ( ) from converter import config from converter.tts import transcribe_reference_audio, whisper_backend_available -from ui import tui +from ui import taskview, tui DEFAULT_HOST = "127.0.0.1" FALLBACK_PORT = 8080 @@ -532,10 +534,18 @@ def build_server_config(host: str, port: int, backend: str, lazy_load: bool, return config_doc -def transcribe_wav_dir(wav_files: list, whisper_model: str) -> Dict[str, str]: - """Transcribe each wav file and return a mapping of stem -> transcript.""" +def transcribe_wav_dir(wav_files: list, whisper_model: str, + cancel=None) -> Dict[str, str]: + """Transcribe each wav file and return a mapping of stem -> transcript. + + CANCEL (a ``threading.Event``) is checked between files so the in-TUI + task view can stop a long transcription early. + """ transcripts: Dict[str, str] = {} for wav_file in wav_files: + if cancel is not None and cancel.is_set(): + print("[INFO] Transcription cancelled") + break name = wav_file.stem print(f"[INFO] Transcribing {wav_file.name} (voice '{name}')...") text = transcribe_reference_audio(str(wav_file), model_name=whisper_model) @@ -610,14 +620,14 @@ def _decide_transcription(wav_files: list, existing: Dict[str, str], def _transcribe(args: argparse.Namespace, include_clone: bool, - plan: dict) -> Tuple[Dict[str, str], bool]: + plan: dict, cancel=None) -> Tuple[Dict[str, str], bool]: """Transcribe the wav directory into a stem -> transcript mapping. Returns the mapping and a flag indicating whether it should be written to prompt_text (False when an existing, complete prompt_text is kept as-is). PLAN is always pre-collected — by the TUI (via _decide_transcription and its confirm callbacks) or by _flag_plan for a non-interactive run — so no - questions are asked here. + questions are asked here. CANCEL is checked between files. """ if not include_clone: print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family " @@ -647,11 +657,13 @@ def _transcribe(args: argparse.Namespace, include_clone: bool, "transcripts must be added by hand (see the warning at the end).") if plan["mode"] == "missing": - new_transcripts = transcribe_wav_dir(plan["missing"], args.whisper_model) + new_transcripts = transcribe_wav_dir(plan["missing"], args.whisper_model, + cancel=cancel) transcripts = dict(existing) transcripts.update(new_transcripts) else: - transcripts = transcribe_wav_dir(wav_files, args.whisper_model) + transcripts = transcribe_wav_dir(wav_files, args.whisper_model, + cancel=cancel) return transcripts, True @@ -779,15 +791,20 @@ def _write_and_advise(audiocpp_dir: Path, wav_dir: Optional[Path], def _install_models(audiocpp_dir: Path, install_guidance: List[Tuple[str, str]], - download: bool) -> None: + download: bool, emit=None, cancel=None) -> None: """Print and optionally run the model install commands. One ``python <manager> install <id>`` command per hosted model (de-duped by install id). When DOWNLOAD is True each command is run in the audio.cpp - checkout via ``subprocess.run`` so the models are downloaded automatically; - a failing install is reported as a warning and does not abort the remaining - downloads. When DOWNLOAD is False (or the model manager is missing) the - commands are only printed, copy-pasteable as before. + checkout via ``subprocess`` so the models are downloaded automatically; + a failing install is reported as a warning and does not abort the + remaining downloads. When DOWNLOAD is False (or the model manager is + missing) the commands are only printed, copy-pasteable as before. + + With EMIT given (the in-TUI task view) each download streams its output + to EMIT and — when the checkout's ``model_manager_v2.py`` supports it — + runs with ``--progress --cancel-file`` so the view can show a real byte + progress bar and cancel gracefully. CANCEL aborts a running download. """ manager = audiocpp_dir / "tools" / "model_manager_v2.py" seen: Set[str] = set() @@ -797,6 +814,8 @@ def _install_models(audiocpp_dir: Path, seen.add(install_id) install_ids.append(install_id) + supports_progress = emit is not None and _manager_supports_progress(manager) + if download and not manager.is_file(): print(f"[WARNING] {manager} not found; printing the install commands " "instead of running them") @@ -808,19 +827,50 @@ def _install_models(audiocpp_dir: Path, print(command) continue print(f"[INFO] Downloading {install_id}...") + argv = [sys.executable, str(manager), "install", install_id] + cancel_file: Optional[Path] = None + on_cancel = None + if supports_progress: + fd, cancel_path = tempfile.mkstemp( + prefix="audiocpp_cancel_", suffix=".cancel") + os.close(fd) + cancel_file = Path(cancel_path) + cancel_file.unlink() # absent = not cancelled + argv += ["--progress", "--cancel-file", str(cancel_file)] + on_cancel = cancel_file.touch try: - result = subprocess.run( - [sys.executable, str(manager), "install", install_id], - cwd=str(audiocpp_dir)) + rc = common.run_console_subprocess( + argv, cwd=str(audiocpp_dir), emit=emit, cancel=cancel, + on_cancel=on_cancel) except OSError as exc: print(f"[WARNING] Could not run {command}: {exc}") - continue - if result.returncode != 0: + rc = 1 + finally: + if cancel_file is not None: + try: + cancel_file.unlink() + except OSError: + pass + if rc != 0: print(f"[WARNING] install {install_id} exited with code " - f"{result.returncode}; the model may need to be downloaded " + f"{rc}; the model may need to be downloaded " "by hand") +def _manager_supports_progress(manager: Path) -> bool: + """True when MANAGER (model_manager_v2.py) supports --progress output. + + The ``--progress``/``--cancel-file`` flags are relatively recent; an + older audio.cpp checkout may not have them, so probe the script source + once instead of failing the download with an unknown flag. + """ + try: + text = manager.read_text(encoding="utf-8", errors="ignore") + except OSError: + return False + return "AUDIOCPP_PROGRESS" in text and "--cancel-file" in text + + def _decide_download(audiocpp_dir: Path, confirm: Callable[[str, bool], bool]) -> bool: """Ask whether to download the selected models now. @@ -935,8 +985,6 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser if existing_config else None, "existing_backend": existing_config.get("backend") if existing_config else None, - "existing_lazy": existing_config.get("lazy_load") - if existing_config else None, "existing_voice_dir": existing_config.get("voice_dir") if existing_config else None, "detected_backend": detect_backend(audiocpp_dir), @@ -1148,28 +1196,38 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser s["backend"] = s["detected_backend"] s["build"] = False return _after_backend() - if s["existing_backend"] in BACKENDS: - # Modify flow: keep the backend an existing server.json records - # (already configured, no rebuild needed). - s["backend"] = s["existing_backend"] - s["build"] = False - return _after_backend() + # Not built for any backend yet: always ask which backend the server + # should use and offer to build it — even on a modify run, so a user + # who declined the build the first time is never stranded without a + # way to build from the TUI. return screen_backend def screen_backend(): + # Pre-select the backend an existing server.json records (modify + # flow), so re-running setup lands on the previous choice. backend_options, backend_default = _backend_options(None) + if s["existing_backend"] in BACKENDS: + backend_default = next( + (index for index, (_label, value) in enumerate(backend_options) + if value == s["existing_backend"]), backend_default) backend = tui.menu( - stdscr, "Which inference backend was audiocpp_server " - "built for?", backend_options, + stdscr, "Which inference backend should audiocpp_server " + "use?", backend_options, default_index=backend_default, back_value=_GO_BACK) if backend is _GO_BACK: return tui.Wizard.BACK s["backend"] = backend + if built_server_binary(s["audiocpp_dir"], backend) is not None: + # A checkout with builds for several backends: this one is + # already built, so there is nothing to build. + s["build"] = False + return _after_backend() return screen_build def screen_build(): - # Not built for any backend yet: offer to build it now. The build - # itself runs in the console tail after the wizard. + # Not built for the chosen backend yet: offer to build it now. The + # build itself runs in the TUI task view (or the console tail for + # CLI runs) after the wizard. build = tui.confirm( stdscr, f"audiocpp_server is not built for {s['backend']}. " f"Build it now (runs scripts/build_*)?", @@ -1180,21 +1238,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser return _after_backend() def _after_backend(): - if args.lazy_load: - s["lazy_load"] = True - return _after_lazy() - return screen_lazy - - def screen_lazy(): - default_lazy = len(s["model_entries"]) > 1 - if isinstance(s["existing_lazy"], bool): - default_lazy = s["existing_lazy"] - lazy_load = tui.confirm( - stdscr, "Load models lazily (on first use instead of at " - "startup)", default=default_lazy, cancel_value=_GO_BACK) - if lazy_load is _GO_BACK: - return tui.Wizard.BACK - s["lazy_load"] = lazy_load + s["lazy_load"] = True return _after_lazy() def _after_lazy(): @@ -1306,15 +1350,21 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser # First screen: resolve the checkout directly when it already exists # (the modify flow), so the wizard starts on a real screen. When no - # checkout exists, clone it into ./app/audio.cpp without asking, then + # checkout exists, clone it into ./app/audio.cpp (streaming inside the + # TUI task view, not by dropping to the console) without asking, then # continue the same way. audiocpp_dir = args.audiocpp_dir if audiocpp_dir is None: audiocpp_dir = find_local_checkout() if audiocpp_dir is None: target = APP_DIR / AUDIOCPP_DIR_NAME - with tui.suspend(stdscr): - rc = common.git_clone(AUDIOCPP_GIT_URL, target) + 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))]) + if rc == 130: + # Cancelled from the task view: abort the wizard quietly. + return None if rc != 0: raise _TuiError( f"git clone failed (exit {rc}). Clone " @@ -1511,15 +1561,18 @@ def model_install_hints(audiocpp_dir: Path, def install_models(audiocpp_dir: Path, - guidance: List[Tuple[str, str]]) -> None: + guidance: List[Tuple[str, str]], + emit=None, cancel=None) -> None: """Download the (display name, install id) models via the helper script. Runs ``model_manager_v2.py install`` for each de-duped install id in the - checkout, streaming to the console; a failing install is reported as a - warning and does not abort the rest. Used by the hub's "Download Missing - Models" action (see ``missing_model_install_guidance`` for the mapping). + checkout, streaming to the console (or to EMIT, the in-TUI task view); a + failing install is reported as a warning and does not abort the rest. Used + by the hub's "Download Missing Models" action (see + ``missing_model_install_guidance`` for the mapping). """ - _install_models(audiocpp_dir, guidance, download=True) + _install_models(audiocpp_dir, guidance, download=True, + emit=emit, cancel=cancel) def hand_install_guidance(audiocpp_dir: Path, @@ -1711,6 +1764,39 @@ def find_audiocpp_server_bin(audiocpp_dir: Path) -> Optional[Path]: 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. @@ -1732,24 +1818,77 @@ def find_build_script(audiocpp_dir: Path) -> Optional[Path]: return candidates[0] if candidates else None -def build_audiocpp(audiocpp_dir: Path, backend: str) -> int: - """Build audiocpp_server for BACKEND, streaming output to the console. +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_<timestamp>.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). Run from a console context (after the TUI wizard returns, or - inside ``tui.suspend``). + missing). """ script = find_build_script(audiocpp_dir) if script is None: - print(f"[ERROR] No build script found in {audiocpp_dir}/scripts; " - "build audiocpp_server manually (see the audio.cpp README)") + 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 - print(f"[INFO] Building audiocpp_server for {backend} " - f"({script} --backend {backend} --target audiocpp_server)...") - return common.run_console_subprocess( - ["sh", str(script), "--backend", backend, "--target", - "audiocpp_server"], - cwd=audiocpp_dir) + argv = ["sh", str(script), "--backend", backend, "--target", + "audiocpp_server"] + command = f"cd {audiocpp_dir} && {shlex.join(argv)}" + if emit is None: + print(f"[INFO] Building audiocpp_server for {backend} ({command})...") + 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. + + Every emitted line is also written (and flushed) to + ``app/logs/audiocpp_build_<timestamp>.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) + + tee(f"[INFO] Building audiocpp_server ({command})...") + rc = 0 + try: + 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: @@ -1773,65 +1912,101 @@ def _print_launch_hint(audiocpp_dir: Path, output_path: Path) -> None: f"-release/bin/audiocpp_server --config {output_path}") -def _execute(settings: dict, args: argparse.Namespace) -> int: - """Shared console tail: build, sync, transcribe, write, install, advise. +def _execute_steps(settings: dict, + args: argparse.Namespace) -> List[taskview.TaskStep]: + """Build the ordered setup steps for the in-TUI task view. - Runs after the TUI wizard returns (or after _collect_from_flags for a - non-interactive run): the terminal is plain, so subprocess output and - transcription progress appear normally. + The same work ``_execute`` runs on the console, split into named steps so + the view can show per-step state (build / transcribe / write / download) + and progress. Shared results (the transcription mapping) travel through a + small closure dict. 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 redirect. """ audiocpp_dir = settings["audiocpp_dir"] + state: dict = {} + steps: List[taskview.TaskStep] = [] - # Build audiocpp_server first (the longest step), when requested. if settings.get("build"): - rc = build_audiocpp(audiocpp_dir, settings["backend"]) - if rc != 0: - print(f"[WARNING] build exited with code {rc}; the server.json " - "was still written — build audiocpp_server manually before " - "starting it") + def build(emit, cancel): + rc = build_audiocpp(audiocpp_dir, settings["backend"], + emit=emit, cancel=cancel) + if 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 + steps.append(taskview.TaskStep( + f"Build audiocpp_server ({settings['backend']})", build)) + + def transcribe(emit, cancel): + args.input_dir = settings["wav_dir"] + if settings["include_clone"] and args.input_dir is not None: + transcripts, write_prompt = _transcribe( + args, True, plan=settings["plan"], cancel=cancel) + elif args.input_dir is not None: + print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable " + "family selected, so voice presets are not used") + transcripts, write_prompt = {}, False else: - print("[OK] build complete") - - # Port sync (applied now that the terminal is back). - if settings["sync_port"] is True: - _apply_port_sync(settings["port"], True) - elif settings["sync_port"] is False: - _apply_port_sync(settings["port"], False) - - # Transcription (console; the questions were already answered). - args.input_dir = settings["wav_dir"] - if settings["include_clone"] and args.input_dir is not None: - transcripts, write_prompt = _transcribe(args, True, plan=settings["plan"]) - elif args.input_dir is not None: - print(f"[WARNING] Ignoring {args.input_dir}: no clone-capable family " - "selected, so voice presets are not used") - transcripts, write_prompt = {}, False - else: - transcripts, write_prompt = {}, False - - _write_and_advise( - audiocpp_dir, settings["wav_dir"], settings["output_path"], - settings["model_entries"], settings["install_guidance"], - settings["host"], settings["port"], settings["backend"], - settings["lazy_load"], transcripts, write_prompt) - - # Delete-unused cleanup (modify flow): remove the already-downloaded - # models the new selection dropped. The regenerated server.json already - # only lists the kept entries. - if settings.get("delete_unused"): - removed = delete_model_files(settings["output_path"], - settings["unused_entries"]) - print(f"[OK] Deleted {removed} unused model " - f"{'entry' if removed == 1 else 'entries'} from disk.") - - if len(settings["entry_ids"]) == 1: - _offer_config_model_id_sync(settings["entry_ids"][0], - settings["sync_model_ids"]) - print_empty_transcript_warning(transcripts) - _install_models(audiocpp_dir, settings["install_guidance"], - settings["download"]) - _print_launch_hint(audiocpp_dir, settings["output_path"]) - return 0 + transcripts, write_prompt = {}, False + state["transcripts"] = transcripts + state["write_prompt"] = write_prompt + return 0 + steps.append(taskview.TaskStep("Transcribe reference voices", transcribe)) + + def write(emit, cancel): + # Port sync (applied now that the terminal is back). + if settings["sync_port"] is True: + _apply_port_sync(settings["port"], True) + elif settings["sync_port"] is False: + _apply_port_sync(settings["port"], False) + + _write_and_advise( + audiocpp_dir, settings["wav_dir"], settings["output_path"], + settings["model_entries"], settings["install_guidance"], + settings["host"], settings["port"], settings["backend"], + settings["lazy_load"], state["transcripts"], state["write_prompt"]) + + # Delete-unused cleanup (modify flow): remove the already-downloaded + # models the new selection dropped. The regenerated server.json + # already only lists the kept entries. + if settings.get("delete_unused"): + removed = delete_model_files(settings["output_path"], + settings["unused_entries"]) + print(f"[OK] Deleted {removed} unused model " + f"{'entry' if removed == 1 else 'entries'} from disk.") + + if len(settings["entry_ids"]) == 1: + _offer_config_model_id_sync(settings["entry_ids"][0], + settings["sync_model_ids"]) + print_empty_transcript_warning(state["transcripts"]) + return 0 + steps.append(taskview.TaskStep("Write server.json & sync config", write)) + + def install(emit, cancel): + _install_models(audiocpp_dir, settings["install_guidance"], + settings["download"], emit=emit, cancel=cancel) + _print_launch_hint(audiocpp_dir, settings["output_path"]) + return 0 + install_title = "Download models" if settings.get("download") \ + else "Print model install commands" + steps.append(taskview.TaskStep(install_title, install)) + + return steps + + +def _execute(settings: dict, args: argparse.Namespace) -> int: + """Shared console tail: build, sync, transcribe, write, install, advise. + + Runs after the TUI wizard returns (or after _collect_from_flags for a + non-interactive run): the terminal is plain, so subprocess output and + transcription progress appear normally. The same work as + ``_execute_steps``, run with no emit (console streaming). + """ + return taskview.run_steps_inline(_execute_steps(settings, args)) def setup_screen(stdscr) -> int: @@ -1839,17 +2014,96 @@ def setup_screen(stdscr) -> int: The hub drives this as one screen of its own ``tui.Wizard`` stack, so Esc on the wizard's first screen simply returns here and the hub pops - back to the menu that launched it. The console tail (build/transcribe/ - write) runs under ``tui.suspend`` so the hub's curses session stays - intact. Returns 0 on completion, 1 when the user aborted. + back to the menu that launched it. The setup tail (build/transcribe/ + write/download) runs inside the TUI task view on this same screen, so + the hub's curses session stays intact and the user sees per-step status + and progress instead of being dropped to the console. Returns 0 on + completion, 1 when the user aborted. """ parser = build_parser() args = parser.parse_args([]) settings = _wizard(stdscr, args, parser) if settings is None: return 1 - with tui.suspend(stdscr): - return _execute(settings, args) + return taskview.run_steps(stdscr, "Setting up audio.cpp", + _execute_steps(settings, args)) + + +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, + 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. + """ + checkout = find_local_checkout() + if checkout is None: + tui.flash(stdscr, "No audio.cpp checkout found — install audio.cpp " + "first.", "err") + return 1 + if find_audiocpp_server_bin(checkout) is not None: + tui.flash(stdscr, "audiocpp_server is already built.", "ok") + return 0 + server_config = load_server_config(checkout / "server.json") or {} + recorded = server_config.get("backend") + options, default = _backend_options(None) + if recorded in BACKENDS: + default = next((i for i, (_label, value) in enumerate(options) + if value == recorded), default) + backend = tui.menu( + stdscr, "Which inference backend should audiocpp_server be built " + "for?", options, default_index=default, back_value=_GO_BACK) + if backend is _GO_BACK: + return 1 + rc = taskview.run_steps(stdscr, "Build audiocpp_server", [ + taskview.TaskStep( + f"Build audiocpp_server ({backend})", + lambda emit, cancel: build_audiocpp( + checkout, backend, emit=emit, cancel=cancel))]) + if rc != 0: + return rc + if update_server_backend(backend): + tui.flash(stdscr, f"audiocpp_server built for {backend}.", "ok") + else: + tui.flash(stdscr, f"audiocpp_server built for {backend}. (Could not " + "update server.json's backend field — reconfigure audio.cpp " + "if it was already configured.)", "warn") + return 0 + + +def update_server_backend(backend: str) -> bool: + """Rewrite the 'backend' in the checkout's server.json, or True when none. + + Sets ``backend`` to BACKEND in ``<checkout>/server.json`` (same + ``json.dump`` formatting as the wizard). Returns True when the file now + carries BACKEND, when there is no server.json (nothing to sync), or when + it already does; False when the file exists but cannot be read/written. + """ + checkout = find_local_checkout() + if checkout is None: + return True + server_json = checkout / "server.json" + if not server_json.exists(): + return True + try: + data = json.loads(server_json.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + if not isinstance(data, dict): + return False + if data.get("backend") == backend: + return True + data["backend"] = backend + try: + with server_json.open("w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=False) + handle.write("\n") + except OSError: + return False + return True def run_tui(args: Optional[argparse.Namespace] = None, @@ -1979,7 +2233,7 @@ def _collect_from_flags(args: argparse.Namespace, backend = "cuda" build = False port = args.port if args.port is not None else config_port() - lazy_load = args.lazy_load if args.lazy_load else (len(model_entries) > 1) + lazy_load = True # Output path / overwrite (decline falls back to cwd, then aborts). output_path = args.output if args.output is not None \ @@ -2085,9 +2339,6 @@ 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("--lazy-load", action="store_true", - help="Load models on first use instead of at startup " - "(default: on when more than one model is hosted)") parser.add_argument("--whisper-model", type=str, default="base", help="Whisper model size for transcription " "(default: base)") @@ -2155,13 +2406,20 @@ def detect() -> BackendStatus: launch = format_launch_hint(specs) managed = servers.manages(specs) remote_running, remote_urls = _detect_remote(managed) + # A more specific "part-way set up" label than unavailable/installed: + # cloned but never built, or built but not configured. + partial = "" + if not built: + partial = "downloaded (not built)" + elif not configured: + partial = "built (not configured)" return BackendStatus("audiocpp", "audio.cpp", installed=built, configured=configured, running=managed or remote_running, details=details, launch_hint=launch, servers=specs, managed=managed, remote=remote_running, remote_urls=remote_urls, - models_missing=bool(missing)) + models_missing=bool(missing), partial=partial) def _detect_remote(managed: bool = False) -> Tuple[bool, dict]: |
