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 | |
| 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')
| -rw-r--r-- | app/backends/__init__.py | 9 | ||||
| -rwxr-xr-x | app/backends/audiocpp.py | 522 | ||||
| -rw-r--r-- | app/backends/common.py | 182 | ||||
| -rw-r--r-- | app/backends/envs.py | 14 | ||||
| -rwxr-xr-x | app/backends/faster.py | 119 | ||||
| -rw-r--r-- | app/backends/qwen.py | 98 | ||||
| -rw-r--r-- | app/docs/backend-audiocpp.md | 4 | ||||
| -rw-r--r-- | app/tests/test_backends.py | 20 | ||||
| -rw-r--r-- | app/tests/test_backends_audiocpp.py | 219 | ||||
| -rw-r--r-- | app/tests/test_backends_common.py | 76 | ||||
| -rw-r--r-- | app/tests/test_backends_envs.py | 2 | ||||
| -rw-r--r-- | app/tests/test_backends_faster.py | 24 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 242 | ||||
| -rw-r--r-- | app/tests/test_taskview.py | 231 | ||||
| -rw-r--r-- | app/tests/test_tui.py | 27 | ||||
| -rw-r--r-- | app/ui/hub.py | 165 | ||||
| -rw-r--r-- | app/ui/taskview.py | 537 | ||||
| -rw-r--r-- | app/ui/tui.py | 40 |
18 files changed, 2205 insertions, 326 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py index ef713ba..83f9866 100644 --- a/app/backends/__init__.py +++ b/app/backends/__init__.py @@ -95,6 +95,14 @@ class BackendStatus: downloaded); DETAILS then names them. The backend still counts as ready (the hub surfaces the warning), but a conversion would fail until the models are installed. + + PARTIAL is an optional, more specific label for a backend that is set up + only part-way (neither running nor fully installed): audio.cpp reports + "downloaded (not built)" when its checkout exists but ``audiocpp_server`` + was never built, and "built (not configured)" when the binary exists but + no ``server.json`` does. The hub shows it verbatim (amber) instead of the + generic "unavailable"/"installed" text, and dims the name while the + backend is still unusable. """ key: str label: str @@ -110,6 +118,7 @@ class BackendStatus: remote_urls: Dict[str, str] = field(default_factory=dict) remote_models: List[str] = field(default_factory=list) models_missing: bool = False + partial: str = "" @property def ready(self) -> bool: 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]: diff --git a/app/backends/common.py b/app/backends/common.py index 08c8863..cb573c3 100644 --- a/app/backends/common.py +++ b/app/backends/common.py @@ -11,10 +11,18 @@ run. import os import re +import sys +import time import urllib.parse from pathlib import Path from typing import Dict, List, Optional, Set, Tuple +# Messages queued while the TUI is on screen, printed to the real console +# after the curses session ends (see ui.hub.run). Build/setup steps that +# fail inside the TUI record here so the user gets a copy-pastable command +# and a log path once the TUI exits, instead of losing the output. +_POST_TUI_NOTICES: List[str] = [] + # The tts-audiobook-generator checkout root (where audiobook.py lives). # Everything non-user-facing lives under ./app: the source packages # (backends, converter, ui), the generated dirs (envs, chunks, logs, debug), @@ -24,6 +32,9 @@ TTS_ROOT = Path(__file__).resolve().parent.parent.parent # The single "everything else" directory under TTS_ROOT. APP_DIR = TTS_ROOT / "app" +# app/logs — build/server/conversion logs (already gitignored). +LOG_DIR = APP_DIR / "logs" + # The project's sample-voice directory: .wav files dropped here are offered # as the default source when a setup/configure wizard asks for a wav # directory (both the TUI browser start and the --wavs flag default). @@ -42,6 +53,24 @@ TTS_OUTPUT_DIR = "output" PROMPT_TEXT_FILENAME = "prompt_text" +def record_post_tui_notice(text: str) -> None: + """Queue a message to print to the console after the TUI session ends. + + The TUI runs in a curses session, so ``print`` during it does not reach + the real terminal. Steps that fail inside the TUI (e.g. the audio.cpp + build) record a copy-pastable command and a log path here; ``ui.hub.run`` + drains the queue after the session ends. + """ + _POST_TUI_NOTICES.append(text) + + +def drain_post_tui_notices() -> List[str]: + """Return and clear the queued post-TUI messages.""" + notices = list(_POST_TUI_NOTICES) + _POST_TUI_NOTICES.clear() + return notices + + def normalize_dir_arg(value: str) -> Path: """Normalize a user-supplied path argument. @@ -267,26 +296,155 @@ def write_prompt_text(wav_dir: Path, return prompt_path -def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None) -> int: - """Run a subprocess whose output streams to the plain console. +def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None, + *, emit=None, cancel=None, on_cancel=None) -> int: + """Run a subprocess, streaming output to the console or to EMIT. + + With EMIT None the child inherits the real terminal and its output + appears normally (used by the non-interactive CLI paths and the quick + ``tui.suspend`` actions like uninstall). With EMIT given (a + ``callable(str)``) the child's stdout/stderr are merged, read line by + line (splitting on both ``\\n`` and ``\\r`` so carriage-return progress + updates like git's or tqdm's surface as lines), and each line is passed + to EMIT — the in-TUI task view path. - Used inside ``tui.suspend`` for clone/build/pip steps: the caller has - already left curses mode, so the child inherits the real terminal and - its output appears normally. Returns the process exit code. + CANCEL is an optional ``threading.Event``: once set, ON_CANCEL (if given) + is called (e.g. to touch a ``--cancel-file``), then the child's process + group is terminated (SIGTERM, escalating to SIGKILL after a grace + period) and 130 is returned. Returns the process exit code. """ import subprocess + if emit is None: + try: + result = subprocess.run(argv, + cwd=str(cwd) if cwd is not None else None) + except OSError as exc: + print(f"[ERROR] Could not run {' '.join(argv)}: {exc}") + return 1 + return result.returncode + + popen_kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT} + if cwd is not None: + popen_kwargs["cwd"] = str(cwd) + if sys.platform == "win32": + popen_kwargs["creationflags"] = \ + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + popen_kwargs["start_new_session"] = True try: - result = subprocess.run(argv, cwd=str(cwd) if cwd is not None else None) + proc = subprocess.Popen(argv, **popen_kwargs) except OSError as exc: - print(f"[ERROR] Could not run {' '.join(argv)}: {exc}") + emit(f"[ERROR] Could not run {' '.join(argv)}: {exc}") return 1 - return result.returncode + cancelled = False + + def _reader() -> None: + try: + for raw in iter(proc.stdout.readline, b""): + if not raw: + break + text = raw.decode("utf-8", errors="replace") + for line in text.splitlines(): + if line: + emit(line) + except (OSError, ValueError): + pass + + reader = _spawn_reader(_reader) + while True: + if cancel is not None and cancel.is_set(): + cancelled = True + if on_cancel is not None: + try: + on_cancel() + except Exception: + pass + # Give a graceful-cancel hook (e.g. a --cancel-file) a + # moment to let the child exit cleanly before forcing it. + grace_end = time.time() + 3 + while time.time() < grace_end: + if proc.poll() is not None: + break + time.sleep(0.1) + if proc.poll() is None: + _terminate_process_group(proc) + break + if proc.poll() is not None: + break + time.sleep(0.1) + try: + reader.join(timeout=5) + finally: + if reader.is_alive(): + reader.join(timeout=0) + if cancelled: + return 130 + return proc.returncode + + +def _spawn_reader(target): + import threading + thread = threading.Thread(target=target, daemon=True) + thread.start() + return thread + + +def _terminate_process_group(proc) -> None: + """Terminate PROC's process group (SIGTERM, then SIGKILL after a grace). -def git_clone(url: str, target: Path) -> int: - """Clone URL into TARGET, streaming to the console. Returns exit code.""" - print(f"[INFO] Cloning {url} into {target}...") - return run_console_subprocess(["git", "clone", url, str(target)]) + Death is detected with ``proc.poll()`` (which reaps the zombie) rather + than a ``killpg(pgid, 0)`` probe — the latter still succeeds on a + zombie, so it would always wait the full grace period. + """ + import signal + if sys.platform == "win32": + try: + proc.terminate() + except OSError: + pass + deadline = time.time() + 10 + while time.time() < deadline: + if proc.poll() is not None: + return + time.sleep(0.1) + try: + proc.kill() + except OSError: + pass + return + try: + pgid = os.getpgid(proc.pid) + except (ProcessLookupError, OSError): + return + try: + os.killpg(pgid, signal.SIGTERM) + except (ProcessLookupError, OSError): + return + deadline = time.time() + 10 + while time.time() < deadline: + if proc.poll() is not None: + return + time.sleep(0.1) + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, OSError): + pass + proc.wait() + + +def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int: + """Clone URL into TARGET, streaming to the console or to EMIT. Returns + the exit code.""" + if emit is None: + print(f"[INFO] Cloning {url} into {target}...") + return run_console_subprocess(["git", "clone", url, str(target)]) + emit(f"[INFO] Cloning {url} into {target}...") + # --progress makes git report percentage updates even though stderr is + # piped (it normally only does so on a terminal), feeding the task view. + return run_console_subprocess( + ["git", "clone", "--progress", url, str(target)], + emit=emit, cancel=cancel) def pip_install(packages: List[str]) -> int: diff --git a/app/backends/envs.py b/app/backends/envs.py index 5a51a33..7b3c54b 100644 --- a/app/backends/envs.py +++ b/app/backends/envs.py @@ -94,17 +94,23 @@ def install_requirements() -> int: [str(env_python()), "-m", "pip", "install", "-r", str(REQUIREMENTS_PATH)]) -def pip_install(packages: List[str]) -> int: +def pip_install(packages: List[str], *, emit=None, cancel=None) -> int: """pip install PACKAGES into the venv, creating it first if needed. Used by the qwen/faster setup wizards to install backend TTS packages - alongside the app requirements. Returns pip's exit code. + alongside the app requirements. Returns pip's exit code. With EMIT given + (the in-TUI task view) pip runs with ``--progress-bar off`` so its output + is clean status lines rather than carriage-return progress spam. """ if not env_exists() and create_env() != 0: return 1 print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...") - return common.run_console_subprocess( - [str(env_python()), "-m", "pip", "install", *packages]) + argv = [str(env_python()), "-m", "pip", "install"] + if emit is not None: + argv.append("--progress-bar") + argv.append("off") + argv.extend(packages) + return common.run_console_subprocess(argv, emit=emit, cancel=cancel) def pip_uninstall(packages: List[str]) -> int: diff --git a/app/backends/faster.py b/app/backends/faster.py index 36121ff..585e480 100755 --- a/app/backends/faster.py +++ b/app/backends/faster.py @@ -52,7 +52,7 @@ from converter.tts import ( transcribe_reference_audio, whisper_backend_available, ) -from ui import tui +from ui import taskview, tui FASTER_DIR_NAME = "faster-qwen3-tts" FASTER_GIT_URL = "https://github.com/andimarafioti/faster-qwen3-tts" @@ -353,49 +353,78 @@ def _try_language(value: str) -> bool: return False -def _execute(settings: dict) -> int: - """Console tail: install, clone, write voices.json, sync, advise.""" +def _execute_steps(settings: dict) -> List[taskview.TaskStep]: + """Build the ordered setup steps for the in-TUI task view. + + The same work ``_execute`` runs on the console, split into named steps so + the view can show per-step state and progress. Subprocess steps (pip + install, git clone) stream through EMIT and abort on CANCEL; print()-based + steps are captured by the view's stdout redirect. + """ + steps: List[taskview.TaskStep] = [] + if settings["do_install"]: - rc = common.pip_install([FASTER_PIP_PKG]) - if rc != 0: - print(f"[WARNING] pip install failed (exit {rc}); install " - f"{FASTER_PIP_PKG} manually") - else: - print("[OK] faster-qwen3-tts installed") + def install(emit, cancel): + rc = common.pip_install([FASTER_PIP_PKG], emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] pip install failed (exit {rc}); install " + f"{FASTER_PIP_PKG} manually") + else: + print("[OK] faster-qwen3-tts installed") + return rc + steps.append(taskview.TaskStep( + f"Install {FASTER_PIP_PKG}", install)) if settings["do_clone"]: - rc = common.git_clone(FASTER_GIT_URL, _checkout()) - if rc != 0: - print(f"[WARNING] git clone failed (exit {rc}); clone manually: " - f"git clone {FASTER_GIT_URL} {_checkout()}") - else: - print(f"[OK] cloned into {_checkout()}") + def clone(emit, cancel): + rc = common.git_clone(FASTER_GIT_URL, _checkout(), + emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] git clone failed (exit {rc}); clone " + f"manually: git clone {FASTER_GIT_URL} {_checkout()}") + else: + print(f"[OK] cloned into {_checkout()}") + return rc + steps.append(taskview.TaskStep( + "Clone faster-qwen3-tts", clone)) + + def write(emit, cancel): + voices = _write_voices_json(settings["output_path"], + settings["wav_dir"], + settings["language"], + settings["whisper_model"], + settings["plan"]) + if voices is None: + return 1 + + # Sync app/converter/config.py port + default voice. + port = settings["port"] + new_url = common.url_with_port(config.FASTER_API_URL, port) + if new_url != config.FASTER_API_URL: + if common.update_config_value("FASTER_API_URL", new_url): + print(f"[OK] Updated FASTER_API_URL to {new_url}") + else: + print("[WARNING] Could not update FASTER_API_URL; edit " + "app/converter/config.py by hand") + default_voice = next(iter(voices)) + if default_voice != config.FASTER_VOICE: + if common.update_config_value("FASTER_VOICE", default_voice): + print(f"[OK] Updated FASTER_VOICE to {default_voice}") + else: + print("[WARNING] Could not update FASTER_VOICE; edit " + "app/converter/config.py by hand") + + _print_launch_hint(settings["output_path"], port) + return 0 + steps.append(taskview.TaskStep( + "Write voices.json & sync config", write)) + + return steps - voices = _write_voices_json(settings["output_path"], settings["wav_dir"], - settings["language"], settings["whisper_model"], - settings["plan"]) - if voices is None: - return 1 - - # Sync app/converter/config.py port + default voice. - port = settings["port"] - new_url = common.url_with_port(config.FASTER_API_URL, port) - if new_url != config.FASTER_API_URL: - if common.update_config_value("FASTER_API_URL", new_url): - print(f"[OK] Updated FASTER_API_URL to {new_url}") - else: - print("[WARNING] Could not update FASTER_API_URL; edit " - "app/converter/config.py by hand") - default_voice = next(iter(voices)) - if default_voice != config.FASTER_VOICE: - if common.update_config_value("FASTER_VOICE", default_voice): - print(f"[OK] Updated FASTER_VOICE to {default_voice}") - else: - print("[WARNING] Could not update FASTER_VOICE; edit " - "app/converter/config.py by hand") - _print_launch_hint(settings["output_path"], port) - return 0 +def _execute(settings: dict) -> int: + """Console tail: install, clone, write voices.json, sync, advise.""" + return taskview.run_steps_inline(_execute_steps(settings)) def _print_launch_hint(voices_path: Path, port: int) -> None: @@ -415,16 +444,18 @@ 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 (install/clone/ - 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 (install/clone/ + transcribe/write) 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 + instead of being dropped to the console. Returns 0 on completion, 1 when + the user aborted. """ args = build_parser().parse_args([]) settings = _wizard(stdscr, args) if settings is None: return 1 - with tui.suspend(stdscr): - return _execute(settings) + return taskview.run_steps(stdscr, "Setting up faster-qwen3-tts", + _execute_steps(settings)) def run_tui(args: Optional[argparse.Namespace] = None) -> int: diff --git a/app/backends/qwen.py b/app/backends/qwen.py index f1e7c79..7fba7e0 100644 --- a/app/backends/qwen.py +++ b/app/backends/qwen.py @@ -30,7 +30,7 @@ from backends import ( servers, ) from converter import config -from ui import tui +from ui import taskview, tui QWEN_PIP_PKG = "qwen-tts" QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" @@ -143,39 +143,61 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: return tui.Wizard().run(_after_install()) +def _execute_steps(settings: dict) -> List[taskview.TaskStep]: + """Build the ordered setup steps for the in-TUI task view. + + The same work ``_execute`` runs on the console, split into named steps so + the view can show per-step state and progress. The pip install streams + through EMIT and aborts on CANCEL; print()-based steps are captured by + the view's stdout redirect. + """ + steps: List[taskview.TaskStep] = [] + + if settings["do_install"]: + def install(emit, cancel): + rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel) + if rc != 0: + print(f"[WARNING] pip install failed (exit {rc}); install " + f"{QWEN_PIP_PKG} manually") + else: + print(f"[OK] {QWEN_PIP_PKG} installed") + return rc + steps.append(taskview.TaskStep(f"Install {QWEN_PIP_PKG}", install)) + + def sync(emit, cancel): + custom_url = common.url_with_port( + config.QWEN_API_URL, settings["custom_port"]) + if custom_url != config.QWEN_API_URL: + if common.update_config_value("QWEN_API_URL", custom_url): + print(f"[OK] Updated QWEN_API_URL to {custom_url}") + else: + print("[WARNING] Could not update QWEN_API_URL; edit " + "app/converter/config.py by hand") + clone_url = common.url_with_port( + config.CLONE_API_URL, settings["clone_port"]) + if clone_url != config.CLONE_API_URL: + if common.update_config_value("CLONE_API_URL", clone_url): + print(f"[OK] Updated CLONE_API_URL to {clone_url}") + else: + print("[WARNING] Could not update CLONE_API_URL; edit " + "app/converter/config.py by hand") + if settings["speaker"] != config.SPEAKER: + if common.update_config_value("SPEAKER", settings["speaker"]): + print(f"[OK] Updated SPEAKER to {settings['speaker']}") + else: + print("[WARNING] Could not update SPEAKER; edit " + "app/converter/config.py by hand") + + _print_launch_hint(settings["custom_port"], settings["clone_port"]) + return 0 + steps.append(taskview.TaskStep("Sync config & ports", sync)) + + return steps + + def _execute(settings: dict) -> int: """Console tail: install, sync config, advise.""" - if settings["do_install"]: - rc = common.pip_install([QWEN_PIP_PKG]) - if rc != 0: - print(f"[WARNING] pip install failed (exit {rc}); install " - f"{QWEN_PIP_PKG} manually") - else: - print(f"[OK] {QWEN_PIP_PKG} installed") - - custom_url = common.url_with_port(config.QWEN_API_URL, settings["custom_port"]) - if custom_url != config.QWEN_API_URL: - if common.update_config_value("QWEN_API_URL", custom_url): - print(f"[OK] Updated QWEN_API_URL to {custom_url}") - else: - print("[WARNING] Could not update QWEN_API_URL; edit " - "app/converter/config.py by hand") - clone_url = common.url_with_port(config.CLONE_API_URL, settings["clone_port"]) - if clone_url != config.CLONE_API_URL: - if common.update_config_value("CLONE_API_URL", clone_url): - print(f"[OK] Updated CLONE_API_URL to {clone_url}") - else: - print("[WARNING] Could not update CLONE_API_URL; edit " - "app/converter/config.py by hand") - if settings["speaker"] != config.SPEAKER: - if common.update_config_value("SPEAKER", settings["speaker"]): - print(f"[OK] Updated SPEAKER to {settings['speaker']}") - else: - print("[WARNING] Could not update SPEAKER; edit " - "app/converter/config.py by hand") - - _print_launch_hint(settings["custom_port"], settings["clone_port"]) - return 0 + return taskview.run_steps_inline(_execute_steps(settings)) def _print_launch_hint(custom_port: int, clone_port: int) -> None: @@ -195,16 +217,18 @@ 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 (pip install / - config sync) 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 (pip install / config + sync) 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 instead of + being dropped to the console. Returns 0 on completion, 1 when the user + aborted. """ args = build_parser().parse_args([]) settings = _wizard(stdscr, args) if settings is None: return 1 - with tui.suspend(stdscr): - return _execute(settings) + return taskview.run_steps(stdscr, "Setting up qwen-tts", + _execute_steps(settings)) def run_tui(args: Optional[argparse.Namespace] = None) -> int: diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md index db35c81..96c6644 100644 --- a/app/docs/backend-audiocpp.md +++ b/app/docs/backend-audiocpp.md @@ -2,7 +2,9 @@ `--backend audiocpp` talks to `audiocpp_server` from [audio.cpp](https://github.com/0xShug0/audio.cpp), which hosts numerous TTS model families. -The easiest way is the TUI: run `python audiobook.py`, choose **Configure backends… → Install Backend → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Start/Stop Backend Servers** menu or automatically when converting). Run it directly with `python app/backends/audiocpp.py` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `app/envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. +The easiest way is the TUI: run `python audiobook.py`, choose **Configure backends… → Install Backend → audio.cpp**, and it clones `audio.cpp` into `app/audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `app/converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Start/Stop Backend Servers** menu or automatically when converting). The clone, build, transcription and model downloads all run inside the TUI — each shows a status (and, where the tool can measure it, a progress bar), and can be cancelled — instead of dropping to console output. Run it directly with `python app/backends/audiocpp.py` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `app/envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. + +The hub's backend status table distinguishes how far audio.cpp is set up: `unavailable` (nothing present), `downloaded (not built)` (checkout cloned, `audiocpp_server` not built), `built (not configured)` (binary built, no `server.json`), `installed` (ready; or `installed (models missing)` when the config references undownloaded models), and `running` once its server answers. Whenever the checkout exists but `audiocpp_server` is missing, **Configure backends… → Build audio.cpp server** builds it from the TUI (the wizard offers the build during setup too), so a backend whose build you skipped is never stuck as "unavailable". The setup steps run in order — build, then configure, then download models — so **Build audio.cpp server** and **Download Missing Models (audio.cpp)** are never offered at the same time; the model download appears only once the server binary is built. If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 9ecedd1..0d3be37 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -304,20 +304,24 @@ class QwenSetupScreenTests(unittest.TestCase): def test_abort_returns_one_without_executing(self): from backends import qwen with patch.object(qwen, "_wizard", return_value=None) as mk_wizard, \ - patch.object(qwen, "_execute") as mk_execute: + patch.object(qwen, "_execute_steps") as mk_steps: rc = qwen.setup_screen(None) self.assertEqual(rc, 1) mk_wizard.assert_called_once() - mk_execute.assert_not_called() + mk_steps.assert_not_called() - def test_success_executes_the_tail_under_suspend(self): - import contextlib + def test_success_runs_the_tail_in_the_task_view(self): from backends import qwen settings = {"custom_port": 7860} + steps = [qwen.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(qwen, "_wizard", return_value=settings), \ - patch.object(qwen, "_execute", return_value=0) as mk_execute, \ - patch.object(qwen.tui, "suspend", contextlib.nullcontext): + patch.object(qwen, "_execute_steps", + return_value=steps) as mk_steps, \ + patch.object(qwen.taskview, "run_steps", + return_value=0) as mk_run: rc = qwen.setup_screen(None) self.assertEqual(rc, 0) - mk_execute.assert_called_once() - self.assertIs(mk_execute.call_args[0][0], settings) + mk_steps.assert_called_once() + self.assertIs(mk_steps.call_args[0][0], settings) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][2], steps) diff --git a/app/tests/test_backends_audiocpp.py b/app/tests/test_backends_audiocpp.py index b724c0d..8f79085 100644 --- a/app/tests/test_backends_audiocpp.py +++ b/app/tests/test_backends_audiocpp.py @@ -5,6 +5,7 @@ import io import json import sys import tempfile +import threading import unittest from contextlib import redirect_stdout from pathlib import Path @@ -792,6 +793,46 @@ class FindAudiocppServerBinTests(unittest.TestCase): self.assertIsNone(make_server.find_audiocpp_server_bin(self.checkout)) +class BuiltServerBinaryTests(unittest.TestCase): + """built_server_binary: locating a specific backend's build.""" + + def setUp(self): + self._td = tempfile.TemporaryDirectory() + self.checkout = Path(self._td.name) / "audio.cpp" + self.checkout.mkdir() + + def tearDown(self): + self._td.cleanup() + + def _build(self, name, binary="audiocpp_server"): + bin_dir = self.checkout / "build" / name / "bin" + bin_dir.mkdir(parents=True) + (bin_dir / binary).write_bytes(b"x") + + def test_returns_the_matching_backend_binary(self): + self._build("linux-cuda-release") + self._build("linux-cpu-release") + self.assertEqual( + make_server.built_server_binary(self.checkout, "cpu"), + self.checkout / "build" / "linux-cpu-release" / "bin" + / "audiocpp_server") + + def test_returns_none_for_unbuilt_backend(self): + self._build("linux-cuda-release") + self.assertIsNone( + make_server.built_server_binary(self.checkout, "vulkan")) + + def test_metal_counts_as_cpu(self): + self._build("macos-metal-release") + self.assertEqual( + make_server.built_server_binary(self.checkout, "cpu"), + self.checkout / "build" / "macos-metal-release" / "bin" + / "audiocpp_server") + + def test_no_build_dir_returns_none(self): + self.assertIsNone(make_server.built_server_binary(self.checkout, "cpu")) + + class BuildAudiocppTests(unittest.TestCase): """Running the audio.cpp build helper script.""" @@ -803,10 +844,23 @@ class BuildAudiocppTests(unittest.TestCase): self.scripts.mkdir() (self.scripts / "build_linux.sh").write_text("#!/bin/sh\n", encoding="utf-8") + self.log_dir = Path(self._td.name) / "logs" + self.addCleanup(make_server.common.drain_post_tui_notices) def tearDown(self): self._td.cleanup() + def _emit(self): + lines = [] + + def emit(line): + lines.append(line) + + return lines, emit + + def _log_files(self): + return sorted(self.log_dir.glob("audiocpp_build_*.log")) + def test_runs_build_script_with_backend_and_target(self): with patch.object(make_server.common, "run_console_subprocess", return_value=0) as run: @@ -826,6 +880,81 @@ class BuildAudiocppTests(unittest.TestCase): rc = make_server.build_audiocpp(self.checkout, "cuda") self.assertNotEqual(rc, 0) + def test_console_path_writes_no_log_and_no_notice(self): + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=0): + rc = make_server.build_audiocpp(self.checkout, "cuda") + self.assertEqual(rc, 0) + self.assertEqual(self._log_files(), []) + self.assertEqual(make_server.common.drain_post_tui_notices(), []) + + def test_tui_success_writes_log_and_no_notice(self): + emitted, emit = self._emit() + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=0): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit) + self.assertEqual(rc, 0) + self.assertEqual(len(self._log_files()), 1) + log_text = self._log_files()[0].read_text(encoding="utf-8") + self.assertIn("[INFO] Building audiocpp_server", log_text) + self.assertIn("--backend cuda", log_text) + self.assertTrue(emitted) + self.assertEqual(make_server.common.drain_post_tui_notices(), []) + + def test_tui_failure_writes_log_and_records_notice(self): + emitted, emit = self._emit() + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=3): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit) + self.assertEqual(rc, 3) + logs = self._log_files() + self.assertEqual(len(logs), 1) + log_text = logs[0].read_text(encoding="utf-8") + self.assertIn("failed (exit code 3)", log_text) + notices = make_server.common.drain_post_tui_notices() + self.assertEqual(len(notices), 1) + notice = notices[0] + self.assertIn("failed (exit code 3)", notice) + self.assertIn(f"Build log: {logs[0]}", notice) + command = (f"cd {self.checkout} && sh " + f"{self.scripts / 'build_linux.sh'} --backend cuda " + "--target audiocpp_server") + self.assertIn(command, notice) + self.assertIn("Troubleshoot by re-running this command", notice) + self.assertTrue(any("failed (exit code 3)" in line + for line in emitted)) + + def test_tui_cancel_suppresses_notice_but_writes_log(self): + emitted, emit = self._emit() + cancel = threading.Event() + cancel.set() + with patch.object(make_server.common, "LOG_DIR", self.log_dir), \ + patch.object(make_server.common, "run_console_subprocess", + return_value=130): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit, cancel=cancel) + self.assertEqual(rc, 130) + self.assertEqual(len(self._log_files()), 1) + self.assertEqual(make_server.common.drain_post_tui_notices(), []) + + def test_tui_missing_script_records_guidance_notice(self): + for f in self.scripts.iterdir(): + f.unlink() + emitted, emit = self._emit() + with patch.object(make_server.common, "LOG_DIR", self.log_dir): + rc = make_server.build_audiocpp(self.checkout, "cuda", + emit=emit) + self.assertNotEqual(rc, 0) + self.assertEqual(self._log_files(), []) + notices = make_server.common.drain_post_tui_notices() + self.assertEqual(len(notices), 1) + self.assertIn("No build script found", notices[0]) + class AudiocppDetectTests(unittest.TestCase): """backends.audiocpp.detect() status reporting.""" @@ -854,6 +983,7 @@ class AudiocppDetectTests(unittest.TestCase): self.assertFalse(status.installed) self.assertFalse(status.configured) self.assertEqual(status.launch_hint, "") + self.assertEqual(status.partial, "downloaded (not built)") def test_built_and_configured_ready(self): binary = self.checkout / "build" / "linux-cuda-release" / "bin" \ @@ -869,6 +999,19 @@ class AudiocppDetectTests(unittest.TestCase): self.assertTrue(status.configured) self.assertIn(str(binary), status.launch_hint) self.assertIn(str(server_json), status.launch_hint) + self.assertEqual(status.partial, "") + + def test_built_not_configured(self): + binary = self.checkout / "build" / "linux-cuda-release" / "bin" \ + / "audiocpp_server" + binary.parent.mkdir(parents=True) + binary.write_bytes(b"x") + with patch.object(make_server, "find_local_checkout", + return_value=self.checkout): + status = make_server.detect() + self.assertTrue(status.installed) + self.assertFalse(status.configured) + self.assertEqual(status.partial, "built (not configured)") class NonInteractiveMainTests(unittest.TestCase): @@ -918,7 +1061,7 @@ class NonInteractiveMainTests(unittest.TestCase): self.assertEqual(data["host"], "127.0.0.1") self.assertEqual(data["port"], make_server.config_port()) self.assertEqual(data["backend"], "cuda") - self.assertFalse(data["lazy_load"]) + self.assertTrue(data["lazy_load"]) self.assertEqual([m["id"] for m in data["models"]], ["higgs"]) self.assertNotIn("voice_dir", data) @@ -1490,7 +1633,8 @@ class InstallModelsTests(unittest.TestCase): guidance = [("qwen", "qwen3_tts_0_6b_base_q8_0")] with patch.object(make_server, "_install_models") as mk: make_server.install_models(checkout, guidance) - mk.assert_called_once_with(checkout, guidance, download=True) + mk.assert_called_once_with(checkout, guidance, download=True, + emit=None, cancel=None) class HandInstallGuidanceTests(unittest.TestCase): @@ -1533,6 +1677,53 @@ class WizardNavigationTests(unittest.TestCase): make_server.build_parser()) self.assertIsNone(settings) + def test_modify_flow_offers_build_when_not_built(self): + # A server.json recording "vulkan" exists, but nothing is built: the + # wizard must still reach the backend menu (pre-selecting vulkan) and + # offer the build — instead of silently skipping it because the + # existing server.json already records a backend. + checkout = self._checkout() + (checkout / "server.json").write_text( + json.dumps({"models": [], "backend": "vulkan"}), + encoding="utf-8") + catalog = make_server.load_model_catalog(checkout) + supertonic = next(i for i, entry in enumerate(catalog) + if entry["family"] == "supertonic") + confirm_questions = [] + + def fake_tree(*args, **kwargs): + return [(supertonic, "Supertonic-GGUF")] + + def fake_line_edit(stdscr, title, default, **kwargs): + if title == "Bind host": + return "127.0.0.1" + if title == "Port": + return "8080" + return default + + def fake_menu(stdscr, title, options, **kwargs): + return "vulkan" + + def fake_confirm(stdscr, question, **kwargs): + confirm_questions.append(question) + return False # decline the build + + with patch.object(make_server, "find_local_checkout", + return_value=checkout), \ + patch.object(tui, "checkbox_tree", side_effect=fake_tree), \ + patch.object(tui, "line_edit", side_effect=fake_line_edit), \ + patch.object(tui, "menu", side_effect=fake_menu), \ + patch.object(tui, "confirm", side_effect=fake_confirm): + settings = make_server._wizard(None, self._args(), + make_server.build_parser()) + self.assertIsNotNone(settings) + self.assertEqual(settings["backend"], "vulkan") + self.assertFalse(settings["build"]) + # The build offer was shown (and declined); the old modify flow + # skipped it entirely. + self.assertTrue(any("not built for vulkan" in q + for q in confirm_questions)) + def test_bind_host_esc_returns_to_families_tree(self): # Esc on "Bind host" must fall back to the model-family tree, then # re-selecting proceeds through the rest of the wizard. @@ -1601,24 +1792,28 @@ if __name__ == "__main__": class SetupScreenTests(unittest.TestCase): - """setup_screen: the wizard run on the hub's screen, console tail via - suspend.""" + """setup_screen: the wizard run on the hub's screen, setup tail via the + in-TUI task view.""" def test_abort_returns_one_without_executing(self): with patch.object(make_server, "_wizard", return_value=None) as mk_wizard, \ - patch.object(make_server, "_execute") as mk_execute: + patch.object(make_server, "_execute_steps") as mk_steps: rc = make_server.setup_screen(None) self.assertEqual(rc, 1) mk_wizard.assert_called_once() - mk_execute.assert_not_called() + mk_steps.assert_not_called() - def test_success_executes_the_tail_under_suspend(self): + def test_success_runs_the_tail_in_the_task_view(self): settings = {"audiocpp_dir": Path("/x")} + steps = [make_server.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(make_server, "_wizard", return_value=settings), \ - patch.object(make_server, "_execute", - return_value=0) as mk_execute, \ - patch.object(tui, "suspend", contextlib.nullcontext): + patch.object(make_server, "_execute_steps", + return_value=steps) as mk_steps, \ + patch.object(make_server.taskview, "run_steps", + return_value=0) as mk_run: rc = make_server.setup_screen(None) self.assertEqual(rc, 0) - mk_execute.assert_called_once() - self.assertIs(mk_execute.call_args[0][0], settings) + mk_steps.assert_called_once() + self.assertIs(mk_steps.call_args[0][0], settings) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][2], steps) diff --git a/app/tests/test_backends_common.py b/app/tests/test_backends_common.py new file mode 100644 index 0000000..b8a8e90 --- /dev/null +++ b/app/tests/test_backends_common.py @@ -0,0 +1,76 @@ +"""Tests for backends.common subprocess/git helpers. + +The streaming mode of ``run_console_subprocess`` (used by the in-TUI task +view) is exercised with a real child process: output lines are captured and +forwarded, cancellation kills the child and returns 130, and an on_cancel +hook runs first. +""" + +import sys +import threading +import unittest +from unittest import mock + +from backends import common + + +class RunConsoleSubprocessStreamingTests(unittest.TestCase): + def test_streaming_emits_merged_lines(self): + lines = [] + rc = common.run_console_subprocess( + [sys.executable, "-c", + "import sys; sys.stdout.write('hello\\nworld\\n'); " + "sys.stderr.write('oops\\n')"], + emit=lines.append) + self.assertEqual(rc, 0) + # stdout/stderr are merged, in arrival order. + self.assertEqual(sorted(lines), ["hello", "oops", "world"]) + + def test_streaming_returns_the_exit_code(self): + rc = common.run_console_subprocess( + [sys.executable, "-c", "import sys; sys.exit(3)"], + emit=lambda line: None) + self.assertEqual(rc, 3) + + def test_cancel_kills_the_process_and_returns_130(self): + cancel = threading.Event() + cancel.set() + rc = common.run_console_subprocess( + [sys.executable, "-c", "import time; time.sleep(60)"], + emit=lambda line: None, cancel=cancel) + self.assertEqual(rc, 130) + + def test_on_cancel_hook_runs_before_kill(self): + cancel = threading.Event() + cancel.set() + touched = [] + common.run_console_subprocess( + [sys.executable, "-c", "import time; time.sleep(60)"], + emit=lambda line: None, cancel=cancel, + on_cancel=lambda: touched.append(True)) + self.assertEqual(touched, [True]) + + +class GitCloneTests(unittest.TestCase): + def test_git_clone_console_passes_through(self): + with mock.patch.object(common, "run_console_subprocess", + return_value=0) as run: + self.assertEqual(common.git_clone("url", common.Path("/t")), 0) + # Console mode: no --progress flag, plain git clone. + self.assertEqual(run.call_args[0][0], + ["git", "clone", "url", "/t"]) + + def test_git_clone_streaming_adds_progress(self): + emit = lambda line: None + with mock.patch.object(common, "run_console_subprocess", + return_value=0) as run: + self.assertEqual(common.git_clone("url", common.Path("/t"), + emit=emit), 0) + argv = run.call_args[0][0] + self.assertEqual(argv[:3], ["git", "clone", "--progress"]) + self.assertIn("url", argv) + self.assertEqual(run.call_args[1]["emit"], emit) + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py index cf4ecc6..bf27f60 100644 --- a/app/tests/test_backends_envs.py +++ b/app/tests/test_backends_envs.py @@ -73,7 +73,7 @@ class PipInstallTests(unittest.TestCase): def test_creates_env_first_when_missing(self): calls = [] - def fake_run(argv): + def fake_run(argv, **kwargs): calls.append(list(argv)) return 0 diff --git a/app/tests/test_backends_faster.py b/app/tests/test_backends_faster.py index 0da461a..eb1fa28 100644 --- a/app/tests/test_backends_faster.py +++ b/app/tests/test_backends_faster.py @@ -251,24 +251,28 @@ if __name__ == "__main__": class SetupScreenTests(unittest.TestCase): - """setup_screen: the wizard run on the hub's screen, console tail via - suspend.""" + """setup_screen: the wizard run on the hub's screen, setup tail via the + in-TUI task view.""" def test_abort_returns_one_without_executing(self): with patch.object(make_voices, "_wizard", return_value=None) as mk_wizard, \ - patch.object(make_voices, "_execute") as mk_execute: + patch.object(make_voices, "_execute_steps") as mk_steps: rc = make_voices.setup_screen(None) self.assertEqual(rc, 1) mk_wizard.assert_called_once() - mk_execute.assert_not_called() + mk_steps.assert_not_called() - def test_success_executes_the_tail_under_suspend(self): + def test_success_runs_the_tail_in_the_task_view(self): settings = {"wav_dir": Path("/x")} + steps = [make_voices.taskview.TaskStep("t", lambda emit, cancel: 0)] with patch.object(make_voices, "_wizard", return_value=settings), \ - patch.object(make_voices, "_execute", - return_value=0) as mk_execute, \ - patch.object(make_voices.tui, "suspend", contextlib.nullcontext): + patch.object(make_voices, "_execute_steps", + return_value=steps) as mk_steps, \ + patch.object(make_voices.taskview, "run_steps", + return_value=0) as mk_run: rc = make_voices.setup_screen(None) self.assertEqual(rc, 0) - mk_execute.assert_called_once() - self.assertIs(mk_execute.call_args[0][0], settings) + mk_steps.assert_called_once() + self.assertIs(mk_steps.call_args[0][0], settings) + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][2], steps) diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index d6340da..68d0f34 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -4,6 +4,8 @@ The hub drives the same curses widgets as ui/tui.py, so these tests reuse the fake curses/screen from test_tui to run the menu without a terminal. """ +import contextlib +import io import json import tempfile import unittest @@ -90,6 +92,12 @@ class HubHelperTests(unittest.TestCase): installed = BackendStatus("k", "l", installed=True, configured=False) none = BackendStatus("k", "l", installed=False, configured=False) + downloaded = BackendStatus("k", "l", installed=False, + configured=False, + partial="downloaded (not built)") + built_unconfigured = BackendStatus("k", "l", installed=True, + configured=False, + partial="built (not configured)") # running beats installed (a server is up even if not configured); # only a backend that is neither installed nor running is dimmed. self.assertEqual(hub._status_mark(local), @@ -110,6 +118,12 @@ class HubHelperTests(unittest.TestCase): ("unavailable", "err", "dim")) self.assertEqual(hub._status_mark(None), ("unavailable", "err", "dim")) + # Part-way states: amber text; the name is dimmed while the backend + # is still unusable (not installed), bright once it is built. + self.assertEqual(hub._status_mark(downloaded), + ("downloaded (not built)", "warn", "dim")) + self.assertEqual(hub._status_mark(built_unconfigured), + ("built (not configured)", "warn", "body")) class HubMenuTests(unittest.TestCase): @@ -136,6 +150,32 @@ class HubMenuTests(unittest.TestCase): result = hub._Hub(screen).run() self.assertIsNone(result) + def test_run_prints_post_tui_notices_after_session(self): + # The TUI runs in curses, so setup steps queue notices for the + # console; hub.run must print them once the session ends. + def fake_app(stdscr): + hub.common.record_post_tui_notice( + "[ERROR] audio.cpp build failed (exit code 2).\n" + " Build log: /tmp/audiocpp_build_20260101_000000.log") + hub.common.record_post_tui_notice("second notice") + + def fake_wrapper(func, *args, **kwargs): + func(None) + return 0 + + buffer = io.StringIO() + self.curses.wrapper = fake_wrapper + with patch.object(hub, "_app", fake_app), \ + contextlib.redirect_stdout(buffer): + rc = hub.run() + self.assertEqual(rc, 0) + out = buffer.getvalue() + self.assertIn("[ERROR] audio.cpp build failed (exit code 2).", out) + self.assertIn("Build log: /tmp/audiocpp_build_20260101_000000.log", + out) + self.assertIn("second notice", out) + self.assertEqual(hub.common.drain_post_tui_notices(), []) + def test_menu_has_only_configure_settings_and_quit_without_backends(self): # Capture the options handed to tui.menu: with nothing installed or # running, Convert/Server must be absent. @@ -295,6 +335,10 @@ class SubmenuStatusTableTests(unittest.TestCase): return fake_menu + def _labels(self, options): + """Option labels, skipping MENU_SEPARATOR divider rows.""" + return [opt[0] for opt in options if opt is not tui.MENU_SEPARATOR] + def _capture_form(self, captured): def fake_form(stdscr, title, fields, **kwargs): captured["title"] = title @@ -365,6 +409,9 @@ class SubmenuStatusTableTests(unittest.TestCase): }), encoding="utf-8") (checkout / "models" / "present").mkdir(parents=True) (checkout / "models" / "present" / "m.gguf").write_bytes(b"x") + binary = checkout / "build" / "linux-cuda-release" / "bin" + binary.mkdir(parents=True) + (binary / "audiocpp_server").write_bytes(b"x") with patch.object(hub, "REGISTRY", infos), \ patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub.tui, "menu", @@ -374,14 +421,102 @@ class SubmenuStatusTableTests(unittest.TestCase): patch.object(hub.shutil, "which", return_value="/x"): result = hub._Hub(None).screen_configure() self.assertIs(result, tui.Wizard.BACK) - labels = [label for label, _ in captured["options"]] - # A model is missing (download), plus the installed backend's - # configure + uninstall entries. Deleting unused models now lives - # inside the "Configure audio.cpp" wizard, not here. + labels = self._labels(captured["options"]) + # The missing-model download heads the menu as the recommended next + # step (yellow suffix), separated from the rest by a blank line; + # Configure + Uninstall follow. The backend is built, so no "Build" + # action is offered. Deleting unused models now lives inside the + # "Configure audio.cpp" wizard, not here. self.assertEqual( labels, - ["Configure audio.cpp", "Download Missing Models (audio.cpp)", + ["Download Missing Models (audio.cpp)", "Configure audio.cpp", "Uninstall Backend"]) + self.assertEqual(captured["options"][0], + ("Download Missing Models (audio.cpp)", + "download_models", ("[recommended]", "warn"))) + self.assertIs(captured["options"][1], tui.MENU_SEPARATOR) + + def test_configure_backends_menu_offers_build_when_not_built(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] + # installed=False (not built), but configured (server.json exists). + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=True)] + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({ + "models": [{"id": "absent", "path": "models/absent"}], + }), encoding="utf-8") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + labels = self._labels(captured["options"]) + # Not built → the Build action heads the menu as the recommended + # next step (yellow suffix, blank separator below); Uninstall follows + # (a downloaded checkout is removable). A downloaded-but-unbuilt + # checkout is NOT installable, so no "Install Backend" entry, and the + # model download stays hidden until the binary exists — Build and + # Download never coexist. Configure needs an installed (built) + # backend. + self.assertEqual(labels, ["Build audio.cpp server", "Uninstall Backend"]) + self.assertEqual(captured["options"][0], + ("Build audio.cpp server", "build_audiocpp", + ("[recommended]", "warn"))) + self.assertIs(captured["options"][1], tui.MENU_SEPARATOR) + + def test_configure_backends_menu_omits_build_when_built(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True)] + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + (checkout / "server.json").write_text(json.dumps({"models": []}), + encoding="utf-8") + binary = checkout / "build" / "linux-cuda-release" / "bin" + binary.mkdir(parents=True) + (binary / "audiocpp_server").write_bytes(b"x") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + labels = self._labels(captured["options"]) + self.assertNotIn("Build audio.cpp server", labels) + + def test_configure_backends_menu_configure_only_when_built_unconfigured(self): + captured = {} + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, lambda: 0)] + # Built but no server.json: only Configure (the next step) plus + # Uninstall — no Build, no Download, no Install entry. + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=False)] + with tempfile.TemporaryDirectory() as td: + checkout = Path(td) + binary = checkout / "build" / "linux-cuda-release" / "bin" + binary.mkdir(parents=True) + (binary / "audiocpp_server").write_bytes(b"x") + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", + self._capture_menu(captured)), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=checkout), \ + patch.object(hub.shutil, "which", return_value="/x"): + result = hub._Hub(None).screen_configure() + self.assertIs(result, tui.Wizard.BACK) + self.assertEqual(self._labels(captured["options"]), + ["Configure audio.cpp", "Uninstall Backend"]) def test_convert_menu_builds_one_form_with_backend_field(self): captured = {} @@ -1591,13 +1726,7 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual(len(flashes), 1) self.assertEqual(flashes[0][1], "ok") - def test_download_models_action_suspends_and_installs(self): - import contextlib - - @contextlib.contextmanager - def fake_suspend(scr): - yield - + def test_download_models_action_runs_in_task_view_and_installs(self): with tempfile.TemporaryDirectory() as td: checkout = Path(td) (checkout / "server.json").write_text(json.dumps({"models": []}), @@ -1612,11 +1741,24 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): patch.object(hub.audiocpp_backend, "missing_model_install_guidance", return_value=guidance), \ - patch.object(hub.tui, "suspend", fake_suspend), \ - patch.object(hub.audiocpp_backend, "install_models") as mk, \ + patch.object(hub.taskview, "run_steps", + return_value=0) as mk_run, \ + patch.object(hub.audiocpp_backend, + "install_models") as mk_install, \ patch_flash: hub._download_models_action(None) - mk.assert_called_once_with(checkout, guidance) + # The downloads run in the TUI task view (one step), not via + # suspend; executing the step forwards emit/cancel to + # install_models. + mk_run.assert_called_once() + self.assertEqual(mk_run.call_args[0][0], None) + steps = mk_run.call_args[0][2] + self.assertEqual([step.title for step in steps], + ["Download missing models"]) + emit = lambda line: None + steps[0].work(emit, None) + mk_install.assert_called_once_with( + checkout, guidance, emit=emit, cancel=None) self.assertEqual(len(flashes), 1) self.assertEqual(flashes[0][1], "ok") @@ -1650,6 +1792,53 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual([label for label, _ in captured["options"]], ["faster-qwen3-tts"]) + def test_pick_backend_install_skips_downloaded_not_built_audiocpp(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0), + BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=False), + BackendStatus("qwen", "qwen-tts", installed=False, + configured=False)] + # audio.cpp has a checkout (downloaded but not built): its next step + # is the Build action, so it must not reappear in the Install picker. + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=Path("/tmp/audiocpp")): + result = hub._Hub(None)._pick_backend(installed_only=False) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["qwen-tts"]) + + def test_pick_backend_install_lists_audiocpp_without_checkout(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0)] + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=None): + result = hub._Hub(None)._pick_backend(installed_only=False) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["audio.cpp"]) + def test_pick_backend_uninstall_lists_installed_only(self): captured = {} @@ -1674,6 +1863,29 @@ class ConfigureBackendsDispatchTests(unittest.TestCase): self.assertEqual([label for label, _ in captured["options"]], ["qwen-tts"]) + def test_pick_backend_uninstall_lists_downloaded_not_built_audiocpp(self): + captured = {} + + def fake_menu(stdscr, title, options, **kwargs): + captured["options"] = options + return tui.Wizard.BACK + + infos = [BackendInfo("audiocpp", "audio.cpp", lambda: None, + lambda: 0)] + # Downloaded but not built (installed=False): still removable, so the + # uninstall picker must list it (its checkout lives on disk). + statuses = [BackendStatus("audiocpp", "audio.cpp", installed=False, + configured=False)] + with patch.object(hub, "REGISTRY", infos), \ + patch.object(hub, "detect_all", return_value=statuses), \ + patch.object(hub.tui, "menu", fake_menu), \ + patch.object(hub.audiocpp_backend, "find_local_checkout", + return_value=Path("/tmp/audiocpp")): + result = hub._Hub(None)._pick_backend(installed_only=True) + self.assertIsNone(result) + self.assertEqual([label for label, _ in captured["options"]], + ["audio.cpp"]) + class HubNavigationTests(unittest.TestCase): """Esc (and q) steps back exactly one screen across the whole hub.""" diff --git a/app/tests/test_taskview.py b/app/tests/test_taskview.py new file mode 100644 index 0000000..15fc501 --- /dev/null +++ b/app/tests/test_taskview.py @@ -0,0 +1,231 @@ +"""Tests for the in-TUI task view (ui/taskview.py). + +The view is driven the same way as the other TUI widgets: the fake curses +module and recording screen from test_tui stand in for a terminal. The +step-sequencing logic is exercised through ``run_steps_inline`` (no thread), +the progress-line parsing through ``TaskView._ingest_line``, and state +transitions through ``handle_event`` + ``_step_mark`` + ``_result_rc``. +""" + +import sys +import unittest +from unittest.mock import patch + +from tests.test_tui import FakeCurses, FakeScreen +from ui import taskview + + +def _step(title, rc=0): + def work(emit, cancel): + return rc + return taskview.TaskStep(title, work) + + +class _FakeTui: + def setUp(self): + self.curses = FakeCurses() + patcher = patch.dict(sys.modules, {"curses": self.curses}) + patcher.start() + self.addCleanup(patcher.stop) + taskview.tui._THEME.clear() + self.addCleanup(taskview.tui._THEME.clear) + + def make_view(self, steps=(), width=80, height=24): + screen = FakeScreen(width=width, height=height) + with patch.object(taskview.TaskView, "_worker_main", lambda self: None): + view = taskview.TaskView(screen, "Setup", list(steps), + clock=lambda: 1000.0) + return view, screen + + +class RunStepsInlineTests(unittest.TestCase): + def test_runs_steps_in_order_and_returns_zero(self): + order = [] + steps = [ + taskview.TaskStep("a", lambda emit, cancel: order.append("a") or 0), + taskview.TaskStep("b", lambda emit, cancel: order.append("b") or 0), + ] + self.assertEqual(taskview.run_steps_inline(steps), 0) + self.assertEqual(order, ["a", "b"]) + + def test_returns_first_bad_rc_and_continues(self): + order = [] + steps = [ + taskview.TaskStep("a", lambda emit, cancel: order.append("a") or 1), + taskview.TaskStep("b", lambda emit, cancel: order.append("b") or 2), + ] + self.assertEqual(taskview.run_steps_inline(steps), 1) + # The second step still ran (warn-and-continue semantics). + self.assertEqual(order, ["a", "b"]) + + def test_passes_emit_and_cancel_to_each_step(self): + seen = [] + emit = object() + cancel = object() + steps = [taskview.TaskStep( + "a", lambda e, c: seen.append((e, c)) or 0)] + taskview.run_steps_inline(steps, emit=emit, cancel=cancel) + self.assertEqual(seen, [(emit, cancel)]) + + +class LineWriterTests(unittest.TestCase): + def _split(self, text): + lines = [] + writer = taskview._LineWriter(lines.append) + writer.write(text) + writer.flush() + return lines + + def test_splits_on_newline(self): + self.assertEqual(self._split("one\ntwo\n"), ["one", "two"]) + + def test_splits_on_carriage_return(self): + # git/tqdm progress updates use \r; each update is its own line. + self.assertEqual(self._split("a\rb\rc"), ["a", "b", "c"]) + + def test_handles_mixed_terminators_and_no_final_newline(self): + self.assertEqual(self._split("x\ny\r\nz"), ["x", "y", "z"]) + + +class ProgressParsingTests(_FakeTui, unittest.TestCase): + def _ingest(self, line): + view, _ = self.make_view(steps=[_step("a")]) + view._ingest_line(line) + return view + + def test_bytes_progress_is_hidden_from_the_log(self): + view = self._ingest("AUDIOCPP_PROGRESS downloaded=512 total=2048") + self.assertEqual(view._progress, (512, 2048)) + self.assertEqual(view._progress_kind, "bytes") + self.assertEqual(view.log_tail, []) + + def test_percent_progress_kept_in_log(self): + view = self._ingest("[ 45%] Building CXX object foo.o") + self.assertEqual(view._progress, (45, 100)) + self.assertEqual(view._progress_kind, "percent") + self.assertEqual(view.log_tail, ["[ 45%] Building CXX object foo.o"]) + + def test_count_progress_from_ninja(self): + view = self._ingest("[123/456] Compiling bar.cpp") + self.assertEqual(view._progress, (123, 456)) + self.assertEqual(view._progress_kind, "count") + + def test_git_percent_progress(self): + view = self._ingest("Receiving objects: 33% (99/300), 1.2 MiB") + self.assertEqual(view._progress, (33, 100)) + + def test_percent_above_one_hundred_ignored(self): + view = self._ingest("CPU 150% usage") + self.assertIsNone(view._progress) + + def test_plain_line_only_logs(self): + view = self._ingest("[INFO] doing work") + self.assertIsNone(view._progress) + self.assertEqual(view.log_tail, ["[INFO] doing work"]) + + def test_log_tail_is_capped(self): + view, _ = self.make_view(steps=[_step("a")]) + for i in range(taskview._LOG_TAIL + 5): + view._ingest_line(f"line {i}") + self.assertEqual(len(view.log_tail), taskview._LOG_TAIL) + self.assertEqual(view.log_tail[-1], f"line {taskview._LOG_TAIL + 4}") + + +class StateTransitionTests(_FakeTui, unittest.TestCase): + def _steps(self): + return [_step("one"), _step("two")] + + def test_success_flow_marks_steps_ok(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + self.assertEqual(view.current, 0) + view.handle_event({"kind": "step_done", "index": 0, "rc": 0}) + view.handle_event({"kind": "step_start", "index": 1, "title": "two"}) + view.handle_event({"kind": "step_done", "index": 1, "rc": 0}) + view.handle_event({"kind": "finish", "phase": "done", "rc": 0}) + self.assertEqual(view.phase, "done") + self.assertEqual(view._result_rc(), 0) + self.assertEqual(view._step_mark(0), ("[OK]", "ok")) + self.assertEqual(view._step_mark(1), ("[OK]", "ok")) + + def test_failure_marks_step_failed_and_returns_bad_rc(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_done", "index": 0, "rc": 7}) + view.handle_event({"kind": "finish", "phase": "error", "rc": 7}) + self.assertEqual(view.phase, "error") + self.assertEqual(view._result_rc(), 7) + self.assertEqual(view._step_mark(0), ("[FAIL]", "err")) + self.assertEqual(view._step_mark(1), ("[ ]", "dim")) + + def test_cancelled_run_returns_nonzero(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_cancelled", "index": 0}) + view.handle_event({"kind": "finish", "phase": "cancelled", "rc": 1}) + self.assertEqual(view.phase, "cancelled") + self.assertEqual(view._result_rc(), 1) + # The step interrupted by cancel is marked cancelled, not failed. + self.assertEqual(view._step_mark(0), ("[x]", "warn")) + self.assertEqual(view._step_mark(1), ("[ ]", "dim")) + + def test_running_step_shows_a_spinner_mark(self): + view, _ = self.make_view(steps=self._steps()) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + mark, kind = view._step_mark(0) + self.assertEqual(kind, "warn") + self.assertIn("[", mark) + + +class RenderTests(_FakeTui, unittest.TestCase): + def _strings(self, screen): + return " ".join(text for _, _, text, _ in screen.strings) + + def test_running_screen_lists_steps_and_cancel_footer(self): + view, screen = self.make_view(steps=[_step("one"), _step("two")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.render() + text = self._strings(screen) + self.assertIn("one", text) + self.assertIn("two", text) + self.assertIn("Esc or q: cancel", text) + + def test_done_screen_shows_the_completion_footer(self): + view, screen = self.make_view(steps=[_step("one")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view.handle_event({"kind": "step_done", "index": 0, "rc": 0}) + view.handle_event({"kind": "finish", "phase": "done", "rc": 0}) + view.render() + text = self._strings(screen) + self.assertIn("completed", text) + self.assertIn("press any key", text) + + def test_progress_bar_drawn_when_known(self): + view, screen = self.make_view(steps=[_step("one")]) + view.handle_event({"kind": "step_start", "index": 0, "title": "one"}) + view._ingest_line("AUDIOCPP_PROGRESS downloaded=512 total=2048") + view.render() + text = self._strings(screen) + self.assertIn("Progress", text) + + +class LabelTests(unittest.TestCase): + def test_fmt_bytes(self): + self.assertEqual(taskview._fmt_bytes(512), "512B") + self.assertEqual(taskview._fmt_bytes(2048), "2.0KB") + self.assertEqual(taskview._fmt_bytes(5 * 1024 * 1024), "5.0MB") + + def test_progress_label_bytes(self): + self.assertEqual(taskview._progress_label((512, 2048), "bytes"), + "512B / 2.0KB") + + def test_progress_label_count(self): + self.assertEqual(taskview._progress_label((3, 10), "count"), "3/10") + + def test_progress_label_percent(self): + self.assertEqual(taskview._progress_label((45, 100), "percent"), + "45%") + + +if __name__ == "__main__": + unittest.main() diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py index 7c9c800..5b5cb3c 100644 --- a/app/tests/test_tui.py +++ b/app/tests/test_tui.py @@ -227,6 +227,33 @@ class MenuTests(TuiTestCase): with self.assertRaises(tui.WizardCancelled): tui.menu(screen, "Pick", self.OPTIONS) + def test_separator_is_blank_and_skipped_by_cursor(self): + options = [("build", "build"), tui.MENU_SEPARATOR, + ("quit", "quit")] + # Down from the first option must skip the blank divider and land on + # the third option (Enter returns its value, not the separator's). + screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, 10]) + self.assertEqual(tui.menu(screen, "Pick", options), "quit") + + def test_separator_alone_is_rejected(self): + with self.assertRaises(ValueError): + tui.menu(self.screen, "Pick", [tui.MENU_SEPARATOR]) + + def test_suffix_renders_in_its_theme_color(self): + # default_index=1 keeps the suffixed option unselected, so its + # segments keep their own colors instead of the cursor bar. + options = [("Build audio.cpp server", "build", + ("[recommended]", "warn")), ("other", "other")] + screen = FakeScreen(keys=[10]) + tui.menu(screen, "Pick", options, default_index=1) + text = " [recommended]" + attr = next(a for _, _, drawn, a in screen.strings + if drawn == text) + self.assertEqual(attr, tui._THEME["warn"]) + label_attr = next(a for _, _, drawn, a in screen.strings + if drawn == "Build audio.cpp server") + self.assertEqual(label_attr, tui._THEME["body"]) + class MenuTableTests(TuiTestCase): """The optional status table: aligned columns and colored statuses.""" diff --git a/app/ui/hub.py b/app/ui/hub.py index d71fa4c..247dd49 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -10,8 +10,10 @@ The entire hub runs in one curses session, driven by a single ``tui.Wizard`` stack of screens (the ``_Hub`` class below). Every menu/action is a screen that returns the next screen, ``Wizard.BACK`` (Esc/q) to pop one screen, or None to quit. Backend setup wizards and the conversion run view run as -opaque leaf screens on this same session (console tails under -``tui.suspend``); a leaf screen finishes by returning ``Wizard.BACK``, so +opaque leaf screens on this same session (the wizards' long setup tails and +model downloads run inside the ``ui.taskview`` task view, and only the quick +uninstall/start/stop actions use ``tui.suspend``); a leaf screen finishes by +returning ``Wizard.BACK``, so the stack lands back on the menu that launched it. Esc therefore steps back exactly one screen everywhere — on the main menu (an empty stack) it quits. 'q' mirrors Esc on every screen that has no typed text. @@ -56,7 +58,7 @@ from converter.tts import ( BACKEND_QWEN, normalize_language, ) -from ui import runview, tui +from ui import runview, taskview, tui _CANCEL = object() # sentinel: a convert preflight confirm backed out @@ -74,6 +76,12 @@ def run() -> int: return 0 except KeyboardInterrupt: return 130 + finally: + # The curses session is over and the terminal is restored: surface + # anything setup steps queued for the console (e.g. a failed audio.cpp + # build's copy-pastable command and build log path). + for notice in common.drain_post_tui_notices(): + print(notice) return 0 @@ -134,11 +142,15 @@ class _Hub: def screen_configure(self): """One flat menu of backend setup/configure/cleanup actions. - Options are populated from the detected statuses: install (any - uninstalled backend), configure each installed backend, - download/delete audio.cpp models (when a server.json references - models on/off disk), and uninstall. Selecting one pushes the next - screen; Esc pops back to the main menu. + The audio.cpp "next step" — build its server (when a checkout has + no binary) or download its missing models (only once built, so + build > configure > download — Build and Download never appear + together) — heads the menu with a yellow ``[recommended]`` tag, + separated from the rest by a blank line. The remaining options are + populated from the detected statuses: configure each installed + backend, install (backends with nothing on disk), and uninstall. + Selecting one pushes the next screen; Esc pops back to the main + menu. """ while True: statuses = detect_all() @@ -146,25 +158,42 @@ class _Hub: installed = [info for info in REGISTRY if by_key.get(info.key) is not None and by_key[info.key].installed] - options = [(f"Configure {info.label}", ("configure", info.key)) - for info in installed] - if any(info.key not in by_key or not by_key[info.key].installed - for info in REGISTRY): - options.append(("Install Backend", "install")) audiocpp_status = by_key.get("audiocpp") missing = [] - if audiocpp_status is not None and audiocpp_status.installed: + needs_build = False + if audiocpp_status is not None: checkout = audiocpp_backend.find_local_checkout() - server_json = checkout / "server.json" if checkout else None - if server_json is not None and server_json.exists(): - missing = audiocpp_backend.missing_model_entries( - server_json) - if missing: + if checkout is not None: + built = audiocpp_backend.find_audiocpp_server_bin( + checkout) is not None + if not built: + needs_build = True + server_json = checkout / "server.json" + # Models can only be downloaded once the server binary + # exists (build > configure > download), so Build and + # Download never appear together. + if built and audiocpp_status.configured \ + and server_json.exists(): + missing = audiocpp_backend.missing_model_entries( + server_json) + + options = [] + if needs_build: + options.append(("Build audio.cpp server", "build_audiocpp", + ("[recommended]", "warn"))) + elif missing: options.append(("Download Missing Models (audio.cpp)", - "download_models")) - - if installed: + "download_models", + ("[recommended]", "warn"))) + if needs_build or missing: + options.append(tui.MENU_SEPARATOR) + + options += [(f"Configure {info.label}", ("configure", info.key)) + for info in installed] + if any(_installable(info, by_key) for info in REGISTRY): + options.append(("Install Backend", "install")) + if any(_uninstallable(info, by_key) for info in REGISTRY): options.append(("Uninstall Backend", "uninstall")) choice = tui.menu( @@ -183,6 +212,9 @@ class _Hub: if choice == "download_models": _download_models_action(self.stdscr) continue # an inline action: re-show this same menu + if choice == "build_audiocpp": + audiocpp_backend.build_screen(self.stdscr) + continue # an inline action: re-show this same menu _kind, key = choice info = get(key) if info is None: @@ -223,20 +255,22 @@ class _Hub: def _pick_backend(self, installed_only: bool): """Pick a backend for the Install/Uninstall actions. - With INSTALLED_ONLY False every backend is listed (the install - list); with it True only the currently-installed ones are (the - uninstall list). Returns a registry entry, or None to go back. + With INSTALLED_ONLY False every backend with nothing on disk yet is + listed (the install list — audio.cpp only without a checkout, since + a downloaded-but-unbuilt checkout is past install); with it True the + ones with something on disk to remove are (the uninstall list — + including a downloaded-but-unbuilt audio.cpp checkout, which + ``uninstall`` deletes whole). Returns a registry entry, or None to + go back. """ statuses = detect_all() by_key = {st.key: st for st in statuses} if installed_only: candidates = [info for info in REGISTRY - if by_key.get(info.key) is not None - and by_key[info.key].installed] + if _uninstallable(info, by_key)] else: candidates = [info for info in REGISTRY - if by_key.get(info.key) is None - or not by_key[info.key].installed] + if _installable(info, by_key)] if not candidates: tui.flash(self.stdscr, "No backends to list here.") return None @@ -384,15 +418,45 @@ class _Hub: return tui.Wizard.BACK +def _installable(info, by_key: dict) -> bool: + """True when INFO has nothing on disk yet — an install-entry candidate. + + audio.cpp is installable only without a checkout: a downloaded-but-unbuilt + checkout is already past the install step (its next action is the hub's + Build entry), so listing it under "Install Backend" would duplicate that + and suggest re-running setup from scratch. The other backends are + installable while not installed. + """ + if info.key == "audiocpp": + return audiocpp_backend.find_local_checkout() is None + status = by_key.get(info.key) + return status is None or not status.installed + + +def _uninstallable(info, by_key: dict) -> bool: + """True when INFO has something on disk that uninstall removes. + + audio.cpp's ``installed`` flag means *built*, so a downloaded-but-unbuilt + checkout would otherwise miss the Uninstall menu — but its checkout + (binary, models, server.json) lives on disk and ``uninstall()`` removes + it, so it counts too. The other backends' ``installed`` already covers + everything their uninstaller touches. + """ + if info.key == "audiocpp": + return audiocpp_backend.find_local_checkout() is not None + status = by_key.get(info.key) + return status is not None and status.installed + + def _download_models_action(stdscr) -> None: """Run the "Download Missing Models (audio.cpp)" action inside the TUI. - Computes the missing models; when they map to install commands it - suspends curses to stream the downloads, then flashes a result — instead - of silently returning to the main menu. When the checkout/server.json is - missing, nothing is missing, or the models do not map to an install - command, it flashes an explanatory notice (the latter explaining how to - install each model by hand). + Computes the missing models; when they map to install commands it runs + the downloads in the task view (with real byte progress and cancellation) + and flashes a result — instead of dropping to the console. When the + checkout/server.json is missing, nothing is missing, or the models do not + map to an install command, it flashes an explanatory notice (the latter + explaining how to install each model by hand). """ checkout = audiocpp_backend.find_local_checkout() if checkout is None: @@ -415,10 +479,16 @@ def _download_models_action(stdscr) -> None: tui.flash(stdscr, audiocpp_backend.hand_install_guidance( checkout, missing), "err") return - with tui.suspend(stdscr): - audiocpp_backend.install_models(checkout, guidance) - tui.flash(stdscr, "Model download finished. See the output above for " - "any warnings.", "ok") + + def run(emit, cancel): + audiocpp_backend.install_models(checkout, guidance, + emit=emit, cancel=cancel) + return 0 + + taskview.run_steps(stdscr, "Download models", + [taskview.TaskStep("Download missing models", run)]) + tui.flash(stdscr, "Model download finished. Any warnings were shown in " + "the log.", "ok") def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: @@ -428,10 +498,13 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: server this tool started (``status.managed``) — or remotely — a server found by probing its remote URL (``status.remote``); the text names which, e.g. "running [local]", "running [remote]", or - "running [local, remote]". Otherwise 'installed' (orange/warn) when the - backend is present on disk, or 'unavailable' (red/err); a backend that is - neither installed nor running is unusable, so its name is dimmed - (NAME_KIND). A multi-model backend (qwen) also names which models + "running [local, remote]". Otherwise a backend set up only part-way + (``status.partial``) shows that label verbatim (amber), e.g. audio.cpp's + "downloaded (not built)" or "built (not configured)"; 'installed' + (orange/warn) when the backend is present on disk; or 'unavailable' + (red/err). A backend that is neither installed nor running is unusable, + so its name is dimmed (NAME_KIND). A multi-model backend (qwen) also + names which models answered in parentheses, e.g. "running [local, remote] (Base, CustomVoice)". CURSES has no true orange, so the theme's yellow 'warn' is used; it renders amber/orange on most terminals. @@ -448,6 +521,12 @@ def _status_mark(status: Optional[BackendStatus]) -> Tuple[str, str, str]: if status.running_models: text += " (" + ", ".join(status.running_models) + ")" return (text, "ok", "body") + if status is not None and status.partial: + # Part-way set up (audio.cpp: "downloaded (not built)" / + # "built (not configured)"): amber text, name dimmed while the + # backend is still unusable. + name_kind = "dim" if not status.installed else "body" + return (status.partial, "warn", name_kind) if status is not None and status.installed: if status.models_missing and not status.running: return ("installed (models missing)", "warn", "body") diff --git a/app/ui/taskview.py b/app/ui/taskview.py new file mode 100644 index 0000000..c0977ff --- /dev/null +++ b/app/ui/taskview.py @@ -0,0 +1,537 @@ +#!/usr/bin/env python3 +"""A full-screen task runner for long setup steps that stay in the TUI. + +Long backend-setup steps (git clone, audiocpp_server build, model downloads, +pip installs, whisper transcription) used to run under ``tui.suspend``, which +dumped the user into plain console output. This widget keeps them inside the +hub's curses session: a worker thread runs an ordered list of ``TaskStep``s +while the main thread redraws a DOS-style frame showing each step's state +(pending / running with a spinner and elapsed clock / [OK] / [FAIL]), an +optional progress bar for the current step, and a dim scrolling log tail of +the step's output. + +Steps stream their output by calling ``emit(line)`` (or simply printing to +stdout/stderr, which the view captures). The view turns output into progress +three ways, best-effort: + + * ``AUDIOCPP_PROGRESS downloaded=N total=M`` (audio.cpp model downloads, + hidden from the log) — an exact bytes bar; + * ``NN%`` (git ``Receiving objects: 45%``, cmake/make ``[ 45%]``, tqdm) — + a percent bar; + * ``[done/total]`` (ninja build output) — a count bar. + +A ``threading.Event`` passed to every step is set when the user confirms +cancel (Esc/q); subprocess runners kill their child process group, and +in-process steps are expected to check it between units of work. When all +steps finish (or are cancelled) the view shows a summary and waits for a key +press, so a failure is never scrolled away. ``run_steps`` returns the first +non-zero step exit code (0 when every step succeeded). +""" + +import contextlib +import re +import threading +import time +from dataclasses import dataclass +from queue import Empty, Queue +from typing import Callable, List, Optional, Tuple + +from ui import tui + +# Redraw cadence for the timed getch (milliseconds). +_DRAW_TIMEOUT_MS = 250 + +# How many recent output lines the log tail keeps. +_LOG_TAIL = 10 + +# Terminal state: the run is over and the screen waits for a key. +_TERMINAL = ("done", "error", "cancelled") + +# Progress-line matchers, in order of precedence. +_PROGRESS_BYTES = re.compile(r"AUDIOCPP_PROGRESS downloaded=(\d+) total=(\d+)") +_PROGRESS_PERCENT = re.compile(r"(\d{1,3})%") +_PROGRESS_COUNT = re.compile(r"\[(\d+)/(\d+)\]") + +# A spinner frame set for the running step marker. +_SPINNER = ("|", "/", "-", "\\") + + +@dataclass +class TaskStep: + """One step of a task view run. + + WORK is ``work(emit, cancel) -> int``: it streams output lines through + EMIT and returns its exit code (0 = success). CANCEL is a + ``threading.Event`` the view sets when the user confirms cancel; WORK + should stop promptly and may return any code (the view reports the run + as "cancelled" regardless). + """ + title: str + work: Callable[[Callable[[str], None], threading.Event], int] + + +def run_steps(scr, title: str, steps: List[TaskStep]) -> int: + """Run STEPS in order inside the curses screen; return the first bad rc. + + Returns 0 when every step succeeded, otherwise the first non-zero exit + code (a cancelled run returns a non-zero code too). + """ + view = TaskView(scr, title, steps) + return view.run() + + +def run_steps_inline(steps: List[TaskStep], emit=None, cancel=None) -> int: + """Run STEPS in order without the curses view; return the first bad rc. + + The console/CLI counterpart of ``run_steps``: each step's work is called + directly (EMIT None keeps the current plain-console subprocess behavior), + and every step runs even when an earlier one failed — matching how the + wizards warn-and-continue today. + """ + first = 0 + for step in steps: + rc = step.work(emit, cancel) + if rc and not first: + first = rc + return first + + +class TaskView: + """Draws and drives one list of setup steps; see the module docstring.""" + + def __init__(self, scr, title: str, steps: List[TaskStep], + clock: Callable[[], float] = time.time): + import curses + self.curses = curses + self.scr = scr + self.title = title + self.steps = steps + self.theme = tui._ensure_theme(curses) + self._clock = clock + # -- state ----------------------------------------------------- + self.phase = "running" # running | done | error | cancelled + self.current: Optional[int] = None # index of the running step + self.results: List[Optional[int]] = [None] * len(steps) + self.cancelled_step: Optional[int] = None + self.log_tail: List[str] = [] + self._progress: Optional[Tuple[float, float]] = None # (done, total) + self._progress_kind = "" # "bytes" | "percent" | "count" | "" + self.step_started: List[Optional[float]] = [None] * len(steps) + self.finished_at: Optional[float] = None + self.cancelled = False + self.cancelling = False + # -- threads --------------------------------------------------- + self._queue: Queue = Queue() + self._cancel = threading.Event() + self._worker = threading.Thread(target=self._worker_main, daemon=True) + + # ------------------------------------------------------------------ + # Worker + # ------------------------------------------------------------------ + + def _worker_main(self) -> None: + first_failure = 0 + for index, step in enumerate(self.steps): + if self._cancel.is_set(): + break + self._queue.put({"kind": "step_start", "index": index, + "title": step.title}) + try: + with contextlib.redirect_stdout(_LineWriter(self._emit)), \ + contextlib.redirect_stderr(_LineWriter(self._emit)): + rc = step.work(self._emit, self._cancel) + except Exception as exc: # noqa: BLE001 - reported to the view + self._queue.put({"kind": "line", + "text": f"[ERROR] {exc}"}) + rc = 1 + if self._cancel.is_set(): + self._queue.put({"kind": "step_cancelled", "index": index}) + break + self._queue.put({"kind": "step_done", "index": index, "rc": rc}) + if rc != 0: + first_failure = first_failure or rc + # Keep going where the console path would only warn; the + # failing step stays marked [FAIL]. + if self._cancel.is_set(): + self._queue.put({"kind": "finish", "phase": "cancelled", + "rc": first_failure or 1}) + elif first_failure: + self._queue.put({"kind": "finish", "phase": "error", + "rc": first_failure}) + else: + self._queue.put({"kind": "finish", "phase": "done", "rc": 0}) + + def _emit(self, line: str) -> None: + """Forward one output line to the view queue (progress-aware).""" + self._queue.put({"kind": "line", "text": line}) + + # ------------------------------------------------------------------ + # Event handling + # ------------------------------------------------------------------ + + def handle_event(self, event: dict) -> None: + kind = event.get("kind") + if kind == "step_start": + self.current = event["index"] + self.step_started[self.current] = self._now() + self._progress = None + self._progress_kind = "" + elif kind == "line": + text = event.get("text") or "" + self._ingest_line(text) + elif kind == "step_done": + index = event["index"] + self.results[index] = event.get("rc") or 0 + self.current = None + self._progress = None + self._progress_kind = "" + elif kind == "step_cancelled": + self.cancelled_step = event["index"] + self.current = None + self._progress = None + self._progress_kind = "" + elif kind == "finish": + self.phase = event.get("phase") or "done" + self.cancelled = self.phase == "cancelled" + self.finished_at = self._now() + self.current = None + + def _ingest_line(self, text: str) -> None: + """Fold one output line into the log tail and progress bar.""" + line = text.rstrip("\r\n") + if not line: + return + match = _PROGRESS_BYTES.search(line) + if match: + total = int(match.group(2)) + done = int(match.group(1)) + self._progress = (done, total) + self._progress_kind = "bytes" + return # machine-readable progress is not part of the log + match = _PROGRESS_PERCENT.search(line) + if match: + percent = int(match.group(1)) + if percent <= 100: + self._progress = (percent, 100) + self._progress_kind = "percent" + # Fall through: keep the line in the log (the tail already + # collapses rapid \r updates to the last full line). + else: + match = _PROGRESS_COUNT.search(line) + if match: + done = int(match.group(1)) + total = int(match.group(2)) + if total > 0 and done <= total: + self._progress = (done, total) + self._progress_kind = "count" + self.log_tail.append(line) + if len(self.log_tail) > _LOG_TAIL: + del self.log_tail[: len(self.log_tail) - _LOG_TAIL] + + def _finish(self, phase: str) -> None: + self.phase = phase + if self.finished_at is None: + self.finished_at = self._now() + + def _now(self) -> float: + return self._clock() + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def run(self) -> int: + scr = self.scr + try: + scr.timeout(_DRAW_TIMEOUT_MS) + except Exception: + pass + self._worker.start() + first_failure = 0 + try: + while True: + self._drain() + self.render() + key = self._get_key() + if key is None: + continue + if self.phase in _TERMINAL: + return self._result_rc() + if key in (27, ord("q"), 3) and not self.cancelling: + if self._prompt_cancel(): + self._drain() + return self._result_rc() + finally: + self._cancel.set() + + def _result_rc(self) -> int: + """The exit code for the whole run (cancelled counts as failure).""" + if self.cancelled: + return 1 + return next((rc for rc in self.results if rc), 0) + + def _get_key(self) -> Optional[int]: + try: + key = self.scr.getch() + except KeyboardInterrupt: + return 3 + if key == -1: + return None + return key + + def _drain(self) -> None: + while True: + try: + event = self._queue.get_nowait() + except Empty: + return + self.handle_event(event) + + def _prompt_cancel(self) -> bool: + """Esc/q: confirm cancel, then wait for the worker to wind down.""" + self._blocking() + try: + answer = tui.confirm(self.scr, "Cancel this step?", default=False, + cancel_value=False) + finally: + self._nonblocking() + if not answer: + return False + self.cancelling = True + self._cancel.set() + self._worker.join(timeout=60) + return True + + def _blocking(self) -> None: + try: + self.scr.timeout(-1) + except Exception: + pass + + def _nonblocking(self) -> None: + try: + self.scr.timeout(_DRAW_TIMEOUT_MS) + except Exception: + pass + + # ------------------------------------------------------------------ + # Drawing + # ------------------------------------------------------------------ + + def render(self) -> None: + curses, theme = self.curses, self.theme + scr = self.scr + scr.erase() + height, width = scr.getmaxyx() + if height < 12 or width < 40: + _text(scr, theme, height // 2, 2, "Terminal too small", + curses.A_BOLD) + scr.refresh() + return + + _box(scr, curses, theme, height, width) + _text(scr, theme, 0, 2, _fit(f" {self.title} ", width - 4), + theme["title"]) + + inner_x = 2 + y = 2 + # -- step list ------------------------------------------------- + for index, step in enumerate(self.steps): + mark, kind = self._step_mark(index) + label = _fit(f" {step.title} ", max(8, width - inner_x - 14)) + _text(scr, theme, y, inner_x, mark, theme.get(kind, theme["body"])) + _text(scr, theme, y, inner_x + 5, label, theme["body"]) + if index == self.current and self.phase not in _TERMINAL: + started = self.step_started[index] or self._now() + _text(scr, theme, y, inner_x + 5 + len(label) + 1, + f" {_format_elapsed(self._now() - started)}", + theme["dim"]) + y += 1 + + y += 1 + _sep(scr, curses, theme, y, width) + y += 1 + + # -- progress bar ---------------------------------------------- + if self._progress is not None and self.phase not in _TERMINAL: + done, total = self._progress + bar_x = inner_x + 10 + bar_room = max(10, width - bar_x - 16) + filled = 0 + if total: + filled = round(bar_room * min(done, total) / total) + filled = max(0, min(bar_room, filled)) + _text(scr, theme, y, inner_x, "Progress".ljust(9), theme["dim"]) + try: + scr.addstr(y, bar_x, " " * filled, theme["bar"]) + except Exception: + pass + _text(scr, theme, y, bar_x + bar_room + 1, + _progress_label(self._progress, self._progress_kind), + theme["accent"]) + y += 1 + + # -- log tail -------------------------------------------------- + for line in self.log_tail[-_LOG_TAIL:]: + _text(scr, theme, y, inner_x, _fit(line, width - inner_x - 2), + theme["dim"]) + y += 1 + if y >= height - 3: + break + + # -- footer ---------------------------------------------------- + if self.phase == "done": + footer = "completed — press any key to return" + kind = "ok" + elif self.phase == "cancelled": + footer = "cancelled — press any key to return" + kind = "warn" + elif self.phase == "error": + footer = "finished with errors — press any key to return" + kind = "err" + elif self.cancelling: + footer = "cancelling..." + kind = "warn" + else: + footer = "Esc or q: cancel" + kind = "dim" + _text(scr, theme, height - 2, 2, _fit(footer, width - 4), + theme[kind]) + scr.refresh() + + def _step_mark(self, index: int) -> Tuple[str, str]: + """The (mark, kind) for step INDEX.""" + if self.phase in _TERMINAL: + if index == self.cancelled_step: + return "[x]", "warn" + if self.results[index] == 0: + return "[OK]", "ok" + if self.results[index] is not None: + return "[FAIL]", "err" + return "[ ]", "dim" + if index == self.current: + frame = _SPINNER[int(self._now() * 4) % len(_SPINNER)] + return f"[{frame} ]", "warn" + if self.results[index] == 0: + return "[OK]", "ok" + if self.results[index] is not None: + return "[FAIL]", "err" + return "[ ]", "dim" + + +# --------------------------------------------------------------------------- +# Small helpers (module-level for testability) +# --------------------------------------------------------------------------- + +class _LineWriter: + """A file-like object that forwards writes to a per-line callback. + + Handles carriage-return progress updates (git/tqdm) by treating ``\r`` + as a line terminator too, so the last full line always reflects the + latest progress. + """ + + def __init__(self, emit: Callable[[str], None]): + self._emit = emit + self._buffer = "" + + def write(self, text: str) -> int: + if not text: + return 0 + self._buffer += text + while True: + cut = _find_line_end(self._buffer) + if cut < 0: + break + line, self._buffer = self._buffer[:cut], self._buffer[cut + 1:] + if line: + self._emit(line) + return len(text) + + def flush(self) -> None: + if self._buffer: + self._emit(self._buffer) + self._buffer = "" + + def isatty(self) -> bool: + return False + + +def _find_line_end(text: str) -> int: + """Index of the earliest ``\n`` or ``\r`` in TEXT, else -1.""" + newline = text.find("\n") + carriage = text.find("\r") + if newline < 0: + return carriage + if carriage < 0: + return newline + return min(newline, carriage) + + +def _text(scr, theme, y, x, text, attr) -> None: + try: + scr.addstr(y, x, text, attr) + except Exception: + pass + + +def _box(scr, curses, theme, height, width) -> None: + border = theme["border"] + try: + scr.addch(0, 0, curses.ACS_ULCORNER, border) + scr.addch(0, width - 1, curses.ACS_URCORNER, border) + scr.addch(height - 1, 0, curses.ACS_LLCORNER, border) + scr.addch(height - 1, width - 1, curses.ACS_LRCORNER, border) + scr.hline(0, 1, curses.ACS_HLINE, width - 2, border) + scr.hline(height - 1, 1, curses.ACS_HLINE, width - 2, border) + for y in range(1, height - 1): + scr.addch(y, 0, curses.ACS_VLINE, border) + scr.addch(y, width - 1, curses.ACS_VLINE, border) + except Exception: + pass + + +def _sep(scr, curses, theme, y, width) -> None: + try: + scr.addch(y, 0, curses.ACS_LTEE, theme["border"]) + scr.addch(y, width - 1, curses.ACS_RTEE, theme["border"]) + scr.hline(y, 1, curses.ACS_HLINE, width - 2, theme["dim"]) + except Exception: + pass + + +def _fit(text: str, width: int) -> str: + if width < 1: + return "" + if len(text) <= width: + return text + return text[: max(0, width - 1)] + "~" + + +def _format_elapsed(seconds: float) -> str: + seconds = max(0, int(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}:{minutes:02d}:{secs:02d}" + return f"{minutes}:{secs:02d}" + + +def _progress_label(progress: Tuple[float, float], kind: str) -> str: + done, total = progress + if kind == "bytes": + return f"{_fmt_bytes(done)} / {_fmt_bytes(total)}" + if kind == "count": + return f"{int(done)}/{int(total)}" + return f"{int(done)}%" + + +def _fmt_bytes(size: float) -> str: + value = float(size) + for unit in ("B", "KB", "MB", "GB"): + if value < 1024 or unit == "GB": + if unit == "B": + return f"{int(value)}{unit}" + return f"{value:.1f}{unit}" + value /= 1024 + return f"{value:.1f}GB" diff --git a/app/ui/tui.py b/app/ui/tui.py index 0391998..0d47863 100644 --- a/app/ui/tui.py +++ b/app/ui/tui.py @@ -141,6 +141,10 @@ def flash(scr, text: str, kind: str = "warn") -> None: # ordinary character inside text editors). _CANCEL_KEYS = (27, ord("q")) +# A menu() option marker: a bare MENU_SEPARATOR in the options list +# renders a blank, non-selectable divider row between option groups. +MENU_SEPARATOR = object() + # --------------------------------------------------------------------------- # Theme @@ -661,6 +665,12 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, notice_lines: Optional[Sequence[Tuple[str, str]]] = None): """Show OPTIONS as (label, value) pairs; return the chosen value. + Each option is ``(label, value)``, optionally ``(label, value, suffix)`` + where SUFFIX is ``(text, kind)`` rendered in the theme color KIND after + the label (e.g. a yellow ``[recommended]`` tag). A bare ``MENU_SEPARATOR`` + in the list renders a blank, non-selectable divider row, which the cursor + skips over. + The cursor starts on DEFAULT_INDEX; Enter returns the highlighted option's value. Options are left-justified like a DOS list; HELP_LINES are dim, centered explanatory lines shown above them. @@ -686,9 +696,12 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, """ if not options: raise ValueError("menu() needs at least one option") + entries = [opt for opt in options if opt is not MENU_SEPARATOR] + if not entries: + raise ValueError("menu() needs at least one selectable option") frame = Frame(scr, title, "Up/Down = move Enter = select Esc = cancel") - cursor = max(0, min(default_index, len(options) - 1)) + cursor = max(0, min(default_index, len(entries) - 1)) while True: frame.rows = [] for line in help_lines or []: @@ -715,21 +728,34 @@ def menu(scr, title: str, options: Sequence[tuple], default_index: int = 0, frame.theme.get(kind, frame.theme["body"]))], align="left") frame.mark("") - base = len(frame.rows) - for label, _ in options: - frame.mark(label, selectable=True, align="left") - frame.cursor = base + cursor + cursor_rows = [] + for opt in options: + if opt is MENU_SEPARATOR: + frame.mark("", selectable=False, align="left") + continue + label = opt[0] + if len(opt) > 2: + suffix_text, suffix_kind = opt[2] + frame.mark_segments( + [(label, frame.theme["body"]), + (" " + suffix_text, + frame.theme.get(suffix_kind, frame.theme["body"]))], + selectable=True, align="left") + else: + frame.mark(label, selectable=True, align="left") + cursor_rows.append(len(frame.rows) - 1) + frame.cursor = cursor_rows[cursor] frame.draw() key = frame.get_key(cancel_keys=()) if key in _CANCEL_KEYS and back_value is not None: return back_value if key in _CANCEL_KEYS: raise WizardCancelled() - moved = frame.motion(key, cursor, len(options), wrap=True) + moved = frame.motion(key, cursor, len(entries), wrap=True) if moved is not None: cursor = moved elif key in (10, 13): - return options[cursor][1] + return entries[cursor][1] # --------------------------------------------------------------------------- |
