aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 23:49:34 -0400
committerhistoria <historiavg@proton.me>2026-08-24 23:49:34 -0400
commitfe4b2b9eb7fb8aac81f65630720c9079d0a3121a (patch)
tree9c5e5f56d0b931d25e6580f10d09453505eddc2f /app/backends
parentf4b1de303704e13818259d5057d176cd841b6ed8 (diff)
downloadtts-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__.py9
-rwxr-xr-xapp/backends/audiocpp.py522
-rw-r--r--app/backends/common.py182
-rw-r--r--app/backends/envs.py14
-rwxr-xr-xapp/backends/faster.py119
-rw-r--r--app/backends/qwen.py98
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: