diff options
| author | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 23:49:34 -0400 |
| commit | fe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch) | |
| tree | 9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/backends | |
| parent | f4b1de303704e13818259d5057d176cd841b6ed8 (diff) | |
| download | tts-audiobook-generator-fe4b2b9eb7fb8aac81f65630720c9079d0a3121a.tar.gz | |
feat: user-friendly menu gating, clearer install/configure path for backends
Diffstat (limited to 'app/backends')
| -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 |
6 files changed, 715 insertions, 229 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: |
