diff options
| author | historia <historiavg@proton.me> | 2026-08-24 01:57:13 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-24 01:58:17 -0400 |
| commit | c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a (patch) | |
| tree | e9ec4f35c18102d4624d3cd59358d192be7bbfcb | |
| parent | 194c63e4d11e6de9792a736a7b99788f1db78741 (diff) | |
| download | tts-audiobook-generator-c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a.tar.gz | |
feat: manage venv for all backends
| -rw-r--r-- | .gitignore | 9 | ||||
| -rw-r--r-- | README.md | 124 | ||||
| -rwxr-xr-x | audiobook.py | 11 | ||||
| -rw-r--r-- | backends/__init__.py | 41 | ||||
| -rwxr-xr-x | backends/audiocpp.py | 46 | ||||
| -rw-r--r-- | backends/common.py | 19 | ||||
| -rw-r--r-- | backends/envs.py | 185 | ||||
| -rwxr-xr-x | backends/faster.py | 63 | ||||
| -rw-r--r-- | backends/qwen.py | 41 | ||||
| -rw-r--r-- | backends/servers.py | 244 | ||||
| -rw-r--r-- | docs/backend-audiocpp.md | 91 | ||||
| -rw-r--r-- | docs/backend-faster.md | 8 | ||||
| -rw-r--r-- | docs/backend-qwen.md | 6 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | tests/test_backends.py | 11 | ||||
| -rw-r--r-- | tests/test_backends_envs.py | 204 | ||||
| -rw-r--r-- | tests/test_backends_servers.py | 146 | ||||
| -rw-r--r-- | tests/test_hub.py | 145 | ||||
| -rw-r--r-- | ui/hub.py | 177 | ||||
| -rw-r--r-- | voices/.gitkeep | 0 |
20 files changed, 1380 insertions, 193 deletions
@@ -1,17 +1,22 @@ -voices/ chunks/ cache/ logs/ debug/ output/* -!output/.gitkeep input/* +voices/* !input/.gitkeep +!output/.gitkeep +!voices/.gitkeep # Backend checkouts cloned by the setup wizards (backends.audiocpp / .faster) /audio.cpp/ /faster-qwen3-tts/ +# Managed Python environment created by audiobook.py (envs.py) for the app +# requirements and the backend TTS packages (qwen-tts, faster-qwen3-tts). +/envs/ + *.epub input/*.txt *.m4b @@ -15,19 +15,31 @@ The converter sends text extracted from your books to a locally running TTS serv ## Prerequisites -- Python 3.12 +- Python 3.12+ - ffmpeg ## Installation -Create a python 3.12 environment, clone the repo, and install the requirements. +Clone the repo. No manual environment setup is needed — `audiobook.py` +creates and manages its own virtual environment (`envs/tts`) the first time +it runs, installing its requirements and any backend TTS packages into it. ```bash -conda create -n audiobook python=3.12 -y -conda activate audiobook git clone https://git.historia.vg/git/tts-audiobook-generator cd tts-audiobook-generator -pip install -r requirements.txt +python audiobook.py +``` + +On first launch `audiobook.py` creates `envs/tts` (via `python -m venv`), +installs `requirements.txt` into it, and re-launches itself inside that +environment. Backend packages (`qwen-tts`, `faster-qwen3-tts[demo]`) are +pip-installed into the same venv by their setup wizards. + +To add extras manually (e.g. FlashAttention), pip-install into the managed +venv directly: + +```bash +envs/tts/bin/python -m pip install flash-attn ``` Put your book files (epub, etc.) in the `input/` directory. The output goes to `output/`. @@ -42,11 +54,12 @@ python audiobook.py A full-screen TUI opens and shows each backend's status in a table — **unavailable** (red, name dimmed: not installed and no server running), **installed** (orange), or **running** (green, when an external server is already accepting connections on its configured port). From the menu you can: -- **Convert books…** — process the `input/` directory with a ready/running backend (it reads the backend's `server.json` / `voices.json` so you pick the model and voice from menus), or +- **Convert books…** — process the `input/` directory with a ready/running backend (it reads the backend's `server.json` / `voices.json` so you pick the model and voice from menus). If the server isn't running you're offered to start it automatically; after the conversion you're asked whether to stop it, or - **Set up a backend…** — clone, build, and configure a backend end-to-end (audio.cpp, qwen, faster), or -- **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker). +- **Configure a backend…** — regenerate its config (a new `server.json`, rebuild `voices.json`, change ports/speaker), or +- **Server…** — manually start or stop a configured backend's server (the hub spawns it in the managed venv and polls until it accepts connections). -**Convert books…** and **Configure a backend…** only appear once at least one backend is installed or running. +**Configure a backend…** and **Server…** only appear once at least one backend is installed or running. Everything the TUI does can also be scripted with flags: `python audiobook.py --backend audiocpp --model higgs --voice narrator`, or `python -m backends.audiocpp --families higgs_audio_tts --clone --build-backend cuda`. @@ -79,100 +92,11 @@ You need one of the following backends (the TUI sets them up for you; manual ste Other options including backend server URLs/ports are configured in `converter/config.py` -## Backend Option 1: audio.cpp - -`audiocpp` is an easy to use server that hosts numerous TTS model families. - -### Download and build audiocpp_server - -Download and build `audiocpp_server` for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. I'm using one of the helper scripts: - -```bash -git clone https://github.com/0xShug0/audio.cpp -cd audio.cpp -scripts/build_linux.sh --backend cuda --target audiocpp_server -``` - -### Install models - -Download model packages with the python model manager script from the audio.cpp checkout. Each installs to `./models`. Here are two examples, Higgs Audio and Qwen3-TTS: - -```bash -python tools/model_manager_v2.py install higgs_audio_tts_4b_q8_0 -python tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0 -python tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0 -``` - -You can run `python tools/model_manager_v2.py list` to see all available models. - -### Create server.json - -Create a `server.json` config file. One server can host multiple models and multiple cloned voices. The `id:` fields are the model names you will set for `tts-audiobook-generator` with `--model`. - -The easiest way is the TUI: run `python audiobook.py`, choose **Set up a backend… → audio.cpp**, and it clones `audio.cpp` into `./audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `converter/config.py`, and prints the launch command. Run it directly with `python -m backends.audiocpp` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). Make sure you're in a Python environment that has `whisper` (i.e. `conda activate audiobook` before running). The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry (see [Voice design](#voice-design) below). - -```json -{ - "host": "127.0.0.1", - "port": 8080, - "backend": "cuda", - "lazy_load": true, - "voice_dir": "/path/to/clone/wavs", - "models": [ - { - "id": "higgs", - "family": "higgs_audio_tts", - "path": "models/Higgs-Audio-v3-TTS-4B-GGUF", - "task": "tts", - "mode": "offline" - }, - { - "id": "qwen", - "family": "qwen3_tts", - "path": "models/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", - "task": "tts", - "mode": "offline" - }, - { - "id": "qwen-clone", - "family": "qwen3_tts", - "path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF", - "task": "tts", - "mode": "offline" - } - ] -} -``` - -### Run audio.cpp and the audiobook script - -Run the server with this config file. The `audiocpp_server` path will be slightly different depending on your platform and build options: - -```bash -./build/linux-cuda-release/bin/audiocpp_server --config server.json -``` - -In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice` from server.json: - -```bash -# Higgs Audio (clone-only) -python audiobook.py --backend audiocpp --model higgs --voice narrator - -# Qwen3-TTS built-in speaker -python audiobook.py --backend audiocpp --model qwen - -# Qwen3-TTS voice cloning -python audiobook.py --backend audiocpp --model qwen-clone --voice narrator - -# Qwen-TTS voice design -python audiobook.py --backend audiocpp --model qwen-design \ - --instructions "A warm adult female narrator with a British accent" -``` - -## Other TTS Backends +## TTS Backend Setup -Installation and usage documentation for other supported TTS backends is in the `docs/` directory: +Installation and usage documentation for each supported TTS backend is in the `docs/` directory: +- [audio.cpp instructions](docs/backend-audiocpp.md) - [qwen-tts instructions](docs/backend-qwen.md) - [faster-qwen-tts instructions](docs/backend-faster.md) diff --git a/audiobook.py b/audiobook.py index 174d8b4..792b274 100755 --- a/audiobook.py +++ b/audiobook.py @@ -20,6 +20,13 @@ if sys.platform == "win32": except AttributeError: pass +# The managed-environment bootstrap (backends.envs) is stdlib-only and is +# imported here so main() can launch it before any third-party dependency is +# touched. It must NOT run at import time (importing this module must stay +# light so the TUI hub and the tests can import it from any environment); it +# runs only when audiobook.py is executed as a script, from main() below. +from backends import envs as _envs # noqa: I001 + from converter import config from converter.converter import ( AUDIO_FORMATS, @@ -100,6 +107,10 @@ def convert(backend: str = None, voice: str = None, clone: str = None, def main() -> None: """Entry point: TUI hub with no args in a terminal, else argparse CLI.""" + # Run inside the managed venv (envs/tts), creating it (and installing + # requirements.txt) first if needed. A no-op when already there. Done + # here rather than at import time so importing this module is light. + _envs.bootstrap(__file__) # No arguments + interactive terminal -> the TUI hub (set up backends # and process the input directory end-to-end). Anything else is the # scriptable argparse CLI. diff --git a/backends/__init__.py b/backends/__init__.py index 629551e..6a23ab7 100644 --- a/backends/__init__.py +++ b/backends/__init__.py @@ -5,8 +5,15 @@ its setup wizard, its status detection, and the launch command it prints once configured. This package aggregates them into a single registry so ``audiobook.py``'s TUI hub and future tools can iterate backends without hardcoding their names: ``backends.detect_all()`` reports which are set -up (and whether their server is currently running), and -``backends.REGISTRY`` drives the hub's setup/configure menus. +up (and whether their server is currently running), and the registry +drives the hub's setup/configure menus. + +The registry is built lazily on the first call to ``get``/``detect_all``/ +``detect`` (not at package import time), because the backend modules pull +in ``converter.tts`` and its third-party dependencies, which are only +available inside the managed venv that ``audiobook.py`` bootstraps before +importing them. ``backends.envs`` is imported during that bootstrap, so +importing this package must stay cheap and dependency-free. Adding a backend: create ``backends/<name>.py`` exposing ``detect() -> BackendStatus``, ``run_tui() -> int`` and @@ -15,11 +22,26 @@ Adding a backend: create ``backends/<name>.py`` exposing automatically. """ +import shlex from dataclasses import dataclass, field from typing import Callable, List, Optional @dataclass +class ServerSpec: + """One launchable server process for a backend. + + A backend may expose more than one server (qwen runs CustomVoice and Base + on separate ports). ARGV is the exact command line the hub spawns (using + the managed venv's absolute binaries, so no shell activation is needed); + URL is the endpoint ``common.server_running`` probes to decide readiness. + """ + name: str + url: str + argv: List[str] + + +@dataclass class BackendStatus: """How far a backend is set up, plus the command to start it. @@ -29,8 +51,10 @@ class BackendStatus: points at the right port). RUNNING means an external server is currently accepting connections on the configured port (probed by ``backends.common.server_running``). DETAILS are short status lines for - the hub. LAUNCH_HINT is the exact command the user runs to start the - server. + the hub. LAUNCH_HINT is the human-readable command(s) the user runs to + start the server, derived from SERVERS by ``format_launch_hint``. + SERVERS is the machine-usable list of server processes the hub can + start/stop (empty when the backend is not yet configured). """ key: str label: str @@ -39,6 +63,7 @@ class BackendStatus: running: bool = False details: List[str] = field(default_factory=list) launch_hint: str = "" + servers: List[ServerSpec] = field(default_factory=list) @property def ready(self) -> bool: @@ -46,6 +71,11 @@ class BackendStatus: return self.installed and self.configured +def format_launch_hint(servers: List[ServerSpec]) -> str: + """Join a backend's server argvs into a copy-pasteable launch hint.""" + return " ; ".join(shlex.join(s.argv) for s in servers) + + @dataclass class ConfigureAction: """A per-backend "configure" menu entry (e.g. "New server.json").""" @@ -114,6 +144,3 @@ def detect(key: str) -> Optional[BackendStatus]: """Detect a single backend by key.""" info = get(key) return info.detect() if info is not None else None - - -_build_registry() diff --git a/backends/audiocpp.py b/backends/audiocpp.py index 57636a7..486017f 100755 --- a/backends/audiocpp.py +++ b/backends/audiocpp.py @@ -41,24 +41,34 @@ from typing import Callable, Dict, List, Optional, Set, Tuple # Allow running directly (python backends/audiocpp.py) from any cwd. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from ui import tui -from backends import BackendStatus, ConfigureAction -from backends import common +from backends import ( + BackendStatus, + ConfigureAction, + ServerSpec, + common, + format_launch_hint, +) from backends.common import ( CONFIG_PATH, PROMPT_TEXT_FILENAME, TTS_ROOT, + VOICES_DIR, detect_wav_dir, find_wav_files, normalize_dir_arg, read_prompt_text, resolve_wav_dir_arg, + write_prompt_text, +) +from backends.common import ( wav_dir_info as _wav_dir_info, +) +from backends.common import ( wav_dir_preview as _wav_dir_preview, - write_prompt_text, ) from converter import config from converter.tts import transcribe_reference_audio, whisper_backend_available +from ui import tui DEFAULT_HOST = "127.0.0.1" FALLBACK_PORT = 8080 @@ -1145,7 +1155,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser wav_dir = tui.browse_directory( stdscr, "Select the directory with your .wav voices", info=_wav_dir_info, preview=_wav_dir_preview, - start=wav_start if wav_start is not None else Path.cwd(), + start=wav_start if wav_start is not None else VOICES_DIR, back_value=_GO_BACK) if wav_dir is _GO_BACK: step = 3 @@ -1540,8 +1550,8 @@ def _collect_from_flags(args: argparse.Namespace, and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]): sync_model_ids = not args.no_sync_model_ids - # Wav dir + transcription plan. - wav_dir = args.input_dir + # Wav dir + transcription plan (defaults to the project's voices/ dir). + wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR plan: Optional[dict] = None if include_clone and wav_dir is not None: wav_files = find_wav_files(wav_dir) @@ -1582,8 +1592,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--wavs", type=resolve_wav_dir_arg, default=None, dest="input_dir", metavar="WAV_DIR", help="Directory with .wav reference files to publish as " - "a server-level voice_dir cloning library (asked " - "for when omitted in the TUI)") + "a server-level voice_dir cloning library " + f"(default: {VOICES_DIR}; asked for when omitted " + "in the TUI)") parser.add_argument("--output", type=Path, default=None, help="Output path for server.json (default: " "server.json inside the audio.cpp checkout; an " @@ -1664,17 +1675,24 @@ def detect() -> BackendStatus: details.append("not built — run setup to build audiocpp_server") server_json = checkout / "server.json" configured = server_json.exists() + servers: List[ServerSpec] = [] if configured: details.append(f"config: {server_json}") - launch = (f"{binary} --config {server_json}" - if built else - f"./build/<platform>-<backend>-release/bin/" - f"audiocpp_server --config {server_json}") + if built: + servers = [ServerSpec( + "audiocpp", config.AUDIOCPP_API_URL, + [str(binary), "--config", str(server_json)])] + else: + launch = (f"./build/<platform>-<backend>-release/bin/" + f"audiocpp_server --config {server_json}") else: details.append("no server.json — run setup to configure models") + if servers: + launch = format_launch_hint(servers) return BackendStatus("audiocpp", "audio.cpp", installed=built, configured=configured, running=running, - details=details, launch_hint=launch) + details=details, launch_hint=launch, + servers=servers) configure_actions: List[ConfigureAction] = [ diff --git a/backends/common.py b/backends/common.py index d707fdc..2529a8f 100644 --- a/backends/common.py +++ b/backends/common.py @@ -20,6 +20,11 @@ from typing import Dict, List, Optional, Set, Tuple # (./audio.cpp, ./faster-qwen3-tts) so a single tree holds everything. TTS_ROOT = Path(__file__).resolve().parent.parent +# 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). +VOICES_DIR = TTS_ROOT / "voices" + # converter/config.py — rewritten in place by update_config_value so the # converter picks up the host/port/voice a wizard configured. CONFIG_PATH = TTS_ROOT / "converter" / "config.py" @@ -249,8 +254,12 @@ def git_clone(url: str, target: Path) -> int: def pip_install(packages: List[str]) -> int: - """pip install PACKAGES (into the current environment). Returns exit code.""" - print(f"[INFO] pip install {' '.join(packages)}...") - import sys - return run_console_subprocess([sys.executable, "-m", "pip", "install", - *packages]) + """pip install PACKAGES into the managed venv (``envs/tts``). Returns exit code. + + Delegates to ``backends.envs.pip_install`` so backend TTS packages are + installed alongside the app requirements in the tool-managed environment + rather than into whatever interpreter happens to be running the wizard. + The import is local to avoid a circular import (envs imports this module). + """ + from backends import envs + return envs.pip_install(packages) diff --git a/backends/envs.py b/backends/envs.py new file mode 100644 index 0000000..7596db6 --- /dev/null +++ b/backends/envs.py @@ -0,0 +1,185 @@ +"""The managed Python environment for the audiobook generator and its backends. + +audiobook.py is meant to be launched from any Python (a bare system interpreter +is fine): on startup it bootstraps a single tool-managed venv at +``envs/tts`` and re-execs itself inside it. That venv holds both the +audiobook app's own ``requirements.txt`` dependencies and the backend TTS +packages (``qwen-tts``, ``faster-qwen3-tts[demo]``) the setup wizards pip +install, so nothing is ever installed into the launching interpreter's +environment. + +A parent process never needs to "activate" an environment — activation is +just a shell convenience that puts an env's ``bin`` on PATH. Instead every +helper here resolves the env's binaries by absolute path +(``envs/tts/bin/python``, ``envs/tts/bin/qwen-tts-demo``), so the hub can +spawn servers in this env from any parent environment. + +This module is imported before audiobook.py's third-party dependencies, so +it must stay stdlib-only (it may import ``backends.common``, which is also +stdlib-only, but never ``converter`` or the backend modules). +""" + +import hashlib +import os +import sys +from pathlib import Path +from typing import List + +from backends import common + +# The tts-audiobook-generator checkout root (where audiobook.py lives). +TTS_ROOT = Path(__file__).resolve().parent.parent + +# One shared venv for the app requirements and every pip-installed backend. +ENV_DIR = TTS_ROOT / "envs" / "tts" +REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt" + +# Marker file recording the requirements.txt hash last installed into the env, +# so ensure_app_env() re-installs when requirements.txt changes. +MARKER_PATH = ENV_DIR / ".audiobook_env_ready" + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +def env_python() -> Path: + """Absolute path to the venv's python interpreter.""" + return ENV_DIR / ("Scripts/python.exe" if _is_windows() else "bin/python") + + +def env_script(name: str) -> Path: + """Absolute path to a console script installed in the venv (e.g. qwen-tts-demo).""" + subdir = "Scripts" if _is_windows() else "bin" + suffix = ".exe" if _is_windows() else "" + return ENV_DIR / subdir / f"{name}{suffix}" + + +def env_exists() -> bool: + """True when the venv's python interpreter is present on disk.""" + return env_python().is_file() + + +def is_managed_env() -> bool: + """True when the current process is already running inside the managed venv.""" + try: + return Path(sys.executable).resolve() == env_python().resolve() + except OSError: + return False + + +def create_env() -> int: + """Create the venv with the launching interpreter (inherits its version). + + pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m + venv`` exit code; a non-zero result is reported with platform remediation. + """ + print(f"[INFO] creating managed environment at {ENV_DIR}...") + rc = common.run_console_subprocess( + [sys.executable, "-m", "venv", str(ENV_DIR)]) + if rc != 0: + print(f"[ERROR] python -m venv failed (exit {rc}).") + if _is_windows(): + print(" On Windows make sure the launcher has the venv module.") + else: + print(" On Debian/Ubuntu install the venv package, e.g.:") + print(" sudo apt install python3-venv") + return rc + + +def install_requirements() -> int: + """pip install -r requirements.txt into the venv. Returns pip's exit code.""" + print(f"[INFO] pip install -r {REQUIREMENTS_PATH} into {ENV_DIR}...") + return common.run_console_subprocess( + [str(env_python()), "-m", "pip", "install", "-r", str(REQUIREMENTS_PATH)]) + + +def pip_install(packages: List[str]) -> 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. + """ + 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]) + + +def module_available(module: str) -> bool: + """True when MODULE imports inside the venv (e.g. qwen_tts, faster_qwen3_tts). + + A short subprocess probe against the venv's interpreter — the equivalent of + importlib.util.find_spec, but for the managed env rather than the current + one. Used by each backend's ``_is_installed``. + """ + if not env_exists(): + return False + import subprocess + try: + result = subprocess.run( + [str(env_python()), "-c", f"import {module}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _requirements_sha() -> str: + try: + data = REQUIREMENTS_PATH.read_bytes() + except OSError: + return "" + return hashlib.sha256(data).hexdigest() + + +def _marker_valid() -> bool: + try: + return MARKER_PATH.read_text(encoding="utf-8").strip() == _requirements_sha() + except OSError: + return False + + +def _write_marker() -> None: + try: + MARKER_PATH.write_text(_requirements_sha() + "\n", encoding="utf-8") + except OSError: + pass + + +def ensure_app_env() -> None: + """Make sure the venv exists and has the current requirements.txt installed. + + Creates the venv when missing, and (re)installs requirements.txt when it is + missing or has changed since the last install (tracked by a hash marker). + Raises RuntimeError on any failure so the caller can abort before re-exec. + """ + if not env_exists() and create_env() != 0: + raise RuntimeError("could not create the managed environment") + if not _marker_valid(): + if install_requirements() != 0: + raise RuntimeError("pip install -r requirements.txt failed") + _write_marker() + + +def bootstrap(script_path: str) -> None: + """Run audiobook.py inside the managed venv, creating it first if needed. + + A no-op when the current process is already the venv's interpreter. Otherwise + ensures the env (and requirements) are ready, then replaces the process with + the venv's python running the same script and CLI args. Called at the top of + audiobook.py before any third-party import. + """ + if is_managed_env(): + return + try: + ensure_app_env() + except RuntimeError as exc: + print(f"[FATAL] {exc}", file=sys.stderr) + sys.exit(1) + py = str(env_python()) + target = str(Path(script_path).resolve()) + print(f"[INFO] re-launching inside managed environment: {py}") + os.execv(py, [py, target, *sys.argv[1:]]) diff --git a/backends/faster.py b/backends/faster.py index 71be050..50c6102 100755 --- a/backends/faster.py +++ b/backends/faster.py @@ -17,7 +17,6 @@ Usage: """ import argparse -import importlib.util import json import sys from pathlib import Path @@ -25,13 +24,27 @@ from typing import List, Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from ui import tui -from backends import BackendStatus, ConfigureAction -from backends import common -from backends.common import TTS_ROOT, find_wav_files, normalize_dir_arg +from backends import ( + BackendStatus, + ConfigureAction, + ServerSpec, + common, + envs, + format_launch_hint, +) +from backends.common import ( + TTS_ROOT, + VOICES_DIR, + find_wav_files, + normalize_dir_arg, +) from converter import config -from converter.tts import normalize_language, transcribe_reference_audio, \ - whisper_backend_available +from converter.tts import ( + normalize_language, + transcribe_reference_audio, + whisper_backend_available, +) +from ui import tui FASTER_DIR_NAME = "faster-qwen3-tts" FASTER_GIT_URL = "https://github.com/andimarafioti/faster-qwen3-tts" @@ -44,7 +57,7 @@ def _checkout() -> Path: def _is_installed() -> bool: - return importlib.util.find_spec("faster_qwen3_tts") is not None + return envs.module_available("faster_qwen3_tts") def _is_cloned() -> bool: @@ -133,7 +146,7 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: wav_dir = tui.browse_directory( stdscr, "Select the directory with your .wav voices", info=common.wav_dir_info, preview=common.wav_dir_preview, - start=Path.cwd()) + start=VOICES_DIR) language = args.language if language is None: lang_text = tui.line_edit( @@ -239,8 +252,9 @@ def _execute(settings: dict) -> int: def _print_launch_hint(voices_path: Path, port: int) -> None: print() if _is_cloned(): - print("Start the server with:") - print(f" python {_checkout()}/examples/openai_server.py " + py = envs.env_python() + print("Start the server with (or use the hub's 'Server' menu):") + print(f" {py} {_checkout()}/examples/openai_server.py " f"--voices {voices_path} --port {port}") else: print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,") @@ -270,25 +284,23 @@ def run_tui(args: Optional[argparse.Namespace] = None) -> int: def _collect_from_flags(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Optional[dict]: """Build the settings dict from flags for a non-interactive run.""" - if args.input_dir is None: - parser.error("--wavs is required in a non-interactive run (or run " - "without flags for the TUI wizard)") - if not args.input_dir.is_dir(): - parser.error(f"WAV directory not found: {args.input_dir}") + wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR + if not wav_dir.is_dir(): + parser.error(f"WAV directory not found: {wav_dir}") try: language = normalize_language(args.language or config.LANGUAGE) except ValueError as exc: parser.error(str(exc)) output_path = args.output if args.output is not None \ else ((_checkout() / "voices.json") if _is_cloned() - else (args.input_dir / "voices.json")) + else (wav_dir / "voices.json")) if output_path.exists() and not args.force: print("[INFO] Aborted; existing voices.json kept") return None return { "do_install": (not _is_installed()) and not args.skip_install, "do_clone": (not _is_cloned()) and not args.skip_clone, - "wav_dir": args.input_dir, + "wav_dir": wav_dir, "language": language, "whisper_model": args.whisper_model or "base", "output_path": output_path, @@ -303,8 +315,8 @@ def build_parser() -> argparse.ArgumentParser: "build voices.json, and sync converter/config.py.") parser.add_argument("input_dir", type=normalize_dir_arg, nargs="?", default=None, metavar="WAV_DIR", - help="Directory with .wav reference files (required in " - "a non-interactive run; browsed for in the TUI)") + help="Directory with .wav reference files " + f"(default: {VOICES_DIR}; browsed for in the TUI)") parser.add_argument("--output", type=Path, default=None, help="Output path for voices.json (default: " "./faster-qwen3-tts/voices.json, or " @@ -344,13 +356,18 @@ def detect() -> BackendStatus: details.append(f"voices: {voices_json}" if voices_json.exists() else "no voices.json — run setup to create one") launch = "" + servers: List[ServerSpec] = [] if cloned and voices_json.exists(): - launch = (f"python {_checkout()}/examples/openai_server.py " - f"--voices {voices_json} --port {_config_port()}") + argv = [str(envs.env_python()), + str(_checkout() / "examples" / "openai_server.py"), + "--voices", str(voices_json), "--port", str(_config_port())] + servers = [ServerSpec("faster", config.FASTER_API_URL, argv)] + launch = format_launch_hint(servers) return BackendStatus("faster", "faster-qwen3-tts", installed=installed and cloned, configured=configured, running=running, - details=details, launch_hint=launch) + details=details, launch_hint=launch, + servers=servers) def _run_voices_only_tui() -> int: diff --git a/backends/qwen.py b/backends/qwen.py index 60f3bb6..52f7a3f 100644 --- a/backends/qwen.py +++ b/backends/qwen.py @@ -14,18 +14,22 @@ Usage: """ import argparse -import importlib.util -import shutil import sys from pathlib import Path from typing import List, Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from ui import tui -from backends import BackendStatus, ConfigureAction -from backends import common +from backends import ( + BackendStatus, + ConfigureAction, + ServerSpec, + common, + envs, + format_launch_hint, +) from converter import config +from ui import tui QWEN_PIP_PKG = "qwen-tts" QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" @@ -39,9 +43,9 @@ QWEN_SPEAKERS = ("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", "Ryan", def _is_installed() -> bool: - if shutil.which("qwen-tts-demo"): + if envs.env_script("qwen-tts-demo").is_file(): return True - return importlib.util.find_spec("qwen_tts") is not None + return envs.module_available("qwen_tts") def _config_port(url: str, fallback: int) -> int: @@ -144,11 +148,13 @@ def _execute(settings: dict) -> int: def _print_launch_hint(custom_port: int, clone_port: int) -> None: + demo = envs.env_script("qwen-tts-demo") print() - print("Start the servers (in separate terminals):") - print(f" qwen-tts-demo {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " + print("Start the servers (in separate terminals), or use the hub's") + print("'Server' menu / let a conversion start one automatically:") + print(f" {demo} {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " f"--port {custom_port}") - print(f" qwen-tts-demo {QWEN_BASE_MODEL} --ip 127.0.0.1 " + print(f" {demo} {QWEN_BASE_MODEL} --ip 127.0.0.1 " f"--port {clone_port}") print("Then run: python audiobook.py --backend qwen") @@ -219,13 +225,20 @@ def detect() -> BackendStatus: details.append(f"CustomVoice port: {custom_port}") details.append(f"Base (clone) port: {clone_port}") details.append(f"speaker: {config.SPEAKER}") - launch = (f"qwen-tts-demo {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " - f"--port {custom_port} ; qwen-tts-demo {QWEN_BASE_MODEL} " - f"--ip 127.0.0.1 --port {clone_port}") + demo = str(envs.env_script("qwen-tts-demo")) + servers = [ + ServerSpec("qwen-custom", config.QWEN_API_URL, + [demo, QWEN_CUSTOMVOICE_MODEL, "--ip", "127.0.0.1", + "--port", str(custom_port)]), + ServerSpec("qwen-clone", config.CLONE_API_URL, + [demo, QWEN_BASE_MODEL, "--ip", "127.0.0.1", + "--port", str(clone_port)]), + ] return BackendStatus("qwen", "qwen-tts", installed=installed, configured=installed, running=running, details=details, - launch_hint=launch) + launch_hint=format_launch_hint(servers), + servers=servers) configure_actions: List[ConfigureAction] = [ diff --git a/backends/servers.py b/backends/servers.py new file mode 100644 index 0000000..12846f0 --- /dev/null +++ b/backends/servers.py @@ -0,0 +1,244 @@ +"""Start and stop TTS backend servers from the TUI hub. + +Each backend's ``detect()`` returns a list of ``ServerSpec`` — the exact argv +(absolute binaries in the managed venv, no shell activation needed) and the +URL to probe for readiness. This module turns those specs into running +processes: ``start`` spawns the server, streams its output to +``logs/<name>-server.log``, records its pid, and polls the URL until it +accepts connections (model loads are slow, so the timeout is generous); +``stop`` terminates the process group the hub started. + +Everything here runs in the plain console tail after the curses TUI returns +(matching the wizards' build/pip streaming), so progress and log tails appear +normally. Pid/log files live under ``logs/`` which is already gitignored. +""" + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import List + +from backends import common +from backends.common import TTS_ROOT + +LOG_DIR = TTS_ROOT / "logs" + +# How long to wait for a server to accept connections on its URL. First-time +# model loads (especially qwen-tts / faster-qwen3-tts pulling weights into +# VRAM) can take minutes, so this is deliberately generous. +SERVER_START_TIMEOUT = 600 + +# Grace period after SIGTERM before escalating to SIGKILL (POSIX). +STOP_GRACE_SECONDS = 10 + + +def _log_path(name: str) -> Path: + return LOG_DIR / f"{name}-server.log" + + +def _pid_path(name: str) -> Path: + return LOG_DIR / f"{name}-server.pid" + + +def _tail_log(name: str, lines: int = 20) -> None: + """Print the last LINES of the server's log (best-effort).""" + path = _log_path(name) + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return + tail = "\n".join(text.splitlines()[-lines:]) + if tail: + print(f"--- last {lines} lines of {path} ---") + print(tail) + print("---") + + +def _pid_alive(pid: int) -> bool: + """True when a process with PID is still running (POSIX signal-0 probe).""" + if sys.platform == "win32": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + handle = kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + kernel32.CloseHandle(handle) + return True + except OSError: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _kill_pid(pid: int) -> bool: + """Terminate PID (and its process group on POSIX). Returns True when dead.""" + if sys.platform == "win32": + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + return not _pid_alive(pid) + for _ in range(int(STOP_GRACE_SECONDS * 10)): + if not _pid_alive(pid): + return True + time.sleep(0.1) + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + return not _pid_alive(pid) + # POSIX: kill the whole process group (started with start_new_session=True). + try: + pgid = os.getpgid(pid) + except ProcessLookupError: + return True + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return True + except PermissionError: + return False + for _ in range(int(STOP_GRACE_SECONDS * 10)): + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + time.sleep(0.1) + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + return True + + +def start(spec) -> bool: + """Start the server described by SPEC (a ``backends.ServerSpec``). + + Spawns its argv with stdout/stderr to ``logs/<name>-server.log``, records + the pid, and polls ``common.server_running(spec.url)`` until it accepts + connections or ``SERVER_START_TIMEOUT`` elapses. Returns True when the + server is up; on timeout or early exit, prints the log tail and returns + False. A no-op (True) when the server is already running. + """ + argv: List[str] = list(spec.argv) + exe = Path(argv[0]) + if not exe.exists(): + print(f"[ERROR] server executable not found: {exe}") + print(" run 'Set up a backend' for " + f"{spec.name!r} first.") + return False + if common.server_running(spec.url): + print(f"[INFO] {spec.name} server already running on {spec.url}") + return True + + LOG_DIR.mkdir(parents=True, exist_ok=True) + pid_file = _pid_path(spec.name) + if pid_file.exists(): + try: + pid_file.unlink() + except OSError: + pass + + print(f"[INFO] starting {spec.name} server: " + + " ".join(str(a) for a in argv)) + log_handle = _log_path(spec.name).open("w", encoding="utf-8") + popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT} + if sys.platform == "win32": + popen_kwargs["creationflags"] = \ + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + popen_kwargs["start_new_session"] = True + try: + proc = subprocess.Popen(argv, **popen_kwargs) + except OSError as exc: + print(f"[ERROR] could not start server: {exc}") + log_handle.close() + return False + + pid_file.write_text(str(proc.pid), encoding="utf-8") + print(f"[INFO] pid {proc.pid}; logs: {_log_path(spec.name)}") + + deadline = time.time() + SERVER_START_TIMEOUT + while time.time() < deadline: + if proc.poll() is not None: + print(f"[ERROR] {spec.name} server exited with code " + f"{proc.returncode}") + _tail_log(spec.name) + try: + pid_file.unlink() + except OSError: + pass + return False + if common.server_running(spec.url): + print(f"[OK] {spec.name} server is up on {spec.url}") + return True + time.sleep(1) + print(f"[ERROR] {spec.name} server did not start within " + f"{SERVER_START_TIMEOUT}s") + _tail_log(spec.name) + # Leave the pid file in place so stop() can kill it (it may still load). + return False + + +def stop(name: str) -> bool: + """Stop a server previously started by ``start`` (identified by pid file). + + Returns True when the process was terminated (or already gone). Returns + False when there is no pid file — the server was not started by this tool, + so the user must stop it manually (e.g. close its terminal). + """ + pid_file = _pid_path(name) + if not pid_file.exists(): + print(f"[INFO] no pid file for '{name}' " + "(not started by this tool — stop it manually)") + return False + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + print(f"[WARNING] could not read pid file {pid_file}; removing it") + try: + pid_file.unlink() + except OSError: + pass + return False + if not _pid_alive(pid): + print(f"[INFO] {name} server (pid {pid}) already stopped") + try: + pid_file.unlink() + except OSError: + pass + return True + print(f"[INFO] stopping {name} server (pid {pid})...") + killed = _kill_pid(pid) + if killed: + print(f"[OK] {name} server stopped") + else: + print(f"[WARNING] could not stop pid {pid}; stop it manually") + try: + pid_file.unlink() + except OSError: + pass + return killed + + +def pid_for(name: str): + """Return the recorded pid for NAME, or None when no pid file exists.""" + pid_file = _pid_path(name) + if not pid_file.exists(): + return None + try: + return int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None diff --git a/docs/backend-audiocpp.md b/docs/backend-audiocpp.md new file mode 100644 index 0000000..ee511bb --- /dev/null +++ b/docs/backend-audiocpp.md @@ -0,0 +1,91 @@ +# Backend Option 1: audio.cpp + +`--backend audiocpp` talks to `audiocpp_server` from [audio.cpp](https://github.com/0xShug0/audio.cpp), which hosts numerous TTS model families. + +The easiest way is the TUI: run `python audiobook.py`, choose **Set up a backend… → audio.cpp**, and it clones `audio.cpp` into `./audio.cpp` (or reuses an existing checkout), builds `audiocpp_server`, lets you pick model families/packages from an expandable checkbox tree (reading the checkout's `model_specs/`), transcribes `.wav` voices with `whisper`, writes `server.json` into the checkout, syncs `converter/config.py`, and prints the launch command (the hub can also start the server for you via the **Server** menu or automatically when converting). Run it directly with `python -m backends.audiocpp` (flags like `--wavs`, `--families`, `--build-backend`, `--clone` skip the corresponding screens for scripting). The TUI runs in the managed `envs/tts` venv, which includes `whisper` via `requirements.txt`; for a manual setup, make sure `whisper` (or `faster_whisper`) is installed in the environment you run the wizard from. The Qwen3-TTS model tree also offers hosting the VoiceDesign package as a `vdes` entry. + +If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. + +### Download and build audiocpp_server + +Download and build `audiocpp_server` for your platform and backend `(cuda, vulkan, hip, cpu)`. Check [audio.cpp's readme](https://github.com/0xShug0/audio.cpp) for details. I'm using one of the helper scripts: + +```bash +git clone https://github.com/0xShug0/audio.cpp +cd audio.cpp +scripts/build_linux.sh --backend cuda --target audiocpp_server +``` + +### Install models + +Download model packages with the python model manager script from the audio.cpp checkout. Each installs to `./models`. Here are two examples, Higgs Audio and Qwen3-TTS: + +```bash +python tools/model_manager_v2.py install higgs_audio_tts_4b_q8_0 +python tools/model_manager_v2.py install qwen3_tts_1_7b_base_q8_0 +python tools/model_manager_v2.py install qwen3_tts_1_7b_customvoice_q8_0 +``` + +You can run `python tools/model_manager_v2.py list` to see all available models. + +### Create server.json + +Create a `server.json` config file. One server can host multiple models and multiple cloned voices. The `id:` fields are the model names you will set for `tts-audiobook-generator` with `--model`. + +```json +{ + "host": "127.0.0.1", + "port": 8080, + "backend": "cuda", + "lazy_load": true, + "voice_dir": "/path/to/clone/wavs", + "models": [ + { + "id": "higgs", + "family": "higgs_audio_tts", + "path": "models/Higgs-Audio-v3-TTS-4B-GGUF", + "task": "tts", + "mode": "offline" + }, + { + "id": "qwen", + "family": "qwen3_tts", + "path": "models/Qwen3-TTS-12Hz-1.7B-CustomVoice-GGUF", + "task": "tts", + "mode": "offline" + }, + { + "id": "qwen-clone", + "family": "qwen3_tts", + "path": "models/Qwen3-TTS-12Hz-1.7B-Base-GGUF", + "task": "tts", + "mode": "offline" + } + ] +} +``` + +### Run audio.cpp and the audiobook script + +Run the server with this config file. The `audiocpp_server` path will be slightly different depending on your platform and build options: + +```bash +./build/linux-cuda-release/bin/audiocpp_server --config server.json +``` + +In a different terminal, run `audiobook.py`. Pick the TTS `--model` and `--voice` from server.json: + +```bash +# Higgs Audio (clone-only) +python audiobook.py --backend audiocpp --model higgs --voice narrator + +# Qwen3-TTS built-in speaker +python audiobook.py --backend audiocpp --model qwen + +# Qwen3-TTS voice cloning +python audiobook.py --backend audiocpp --model qwen-clone --voice narrator + +# Qwen-TTS voice design +python audiobook.py --backend audiocpp --model qwen-design \ + --instructions "A warm adult female narrator with a British accent" +``` diff --git a/docs/backend-faster.md b/docs/backend-faster.md index 40b10f7..c407a70 100644 --- a/docs/backend-faster.md +++ b/docs/backend-faster.md @@ -2,7 +2,11 @@ `--backend faster` talks to the OpenAI-compatible server from [faster-qwen3-tts](https://github.com/andimarafioti/faster-qwen3-tts), which uses CUDA graph capture for roughly 5-10x faster inference with the same models. **It requires an NVIDIA GPU**. -Install into the **same `audiobook` conda environment** used for qwen-tts. +The easiest way is to run `python audiobook.py` → **Set up a backend… → faster-qwen3-tts** (or `python -m backends.faster path/to/clone/wavs`): the TUI pip-installs `faster-qwen3-tts[demo]` into its managed venv (`envs/tts`), clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. You can also start the server from the hub's **Server** menu, or let a conversion start it automatically. + +If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. + +Install into your environment (the same one used for qwen-tts is fine): ```bash conda activate audiobook @@ -19,7 +23,7 @@ git clone https://github.com/andimarafioti/faster-qwen3-tts cd faster-qwen3-tts ``` -Create a `voices.json` mapping names to reference configurations (.wav to clone, transcript, language). The TUI setup (`python audiobook.py` → **Set up a backend… → faster-qwen3-tts**, or `python -m backends.faster path/to/clone/wavs`) pip-installs the package, clones the repo, transcribes the `.wav` files with `whisper`, and writes `voices.json` for you. +Create a `voices.json` mapping names to reference configurations (.wav to clone, transcript, language). The TUI setup writes this for you; manually it looks like: ```json { diff --git a/docs/backend-qwen.md b/docs/backend-qwen.md index 0c9dab0..028d6f3 100644 --- a/docs/backend-qwen.md +++ b/docs/backend-qwen.md @@ -1,8 +1,10 @@ # Backend Option 2: Qwen3-TTS -The TUI sets this up: run `python audiobook.py` → **Set up a backend… → qwen-tts**, or `python -m backends.qwen`. It pip-installs `qwen-tts` and configures the two ports and built-in speaker in `converter/config.py`, then prints the launch commands. Manual steps: +The easiest way is to run `python audiobook.py` → **Set up a backend… → qwen-tts** (or `python -m backends.qwen`): the TUI pip-installs `qwen-tts` into its managed venv (`envs/tts`), configures the two ports and the built-in speaker in `converter/config.py`, and prints the launch commands. You can also start the server from the hub's **Server** menu, or let a conversion start it automatically. -Install qwen-tts with pip: +If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. + +Install qwen-tts with pip into your environment: ```bash conda activate audiobook diff --git a/requirements.txt b/requirements.txt index 4d7e9fe..17e94f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ ebooklib>=0.18 # Optional dependencies beautifulsoup4>=4.11.0 # better HTML cleaning for EPUB faster-whisper>=1.0.0 # reference-audio transcription for voice cloning -# windows-curses>=2.3 # Windows only: enables the TUI (the audiobook.py hub + backends.* wizards) +windows-curses>=2.3; sys_platform == "win32" # enables the TUI on Windows # Audio processing # Note: ffmpeg is required to concatenate and encode the final audiobook. diff --git a/tests/test_backends.py b/tests/test_backends.py index 8ee1be8..c0e8d4a 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -9,6 +9,13 @@ from backends import REGISTRY, detect_all, get class RegistryTests(unittest.TestCase): + def setUp(self): + # The registry is built lazily on first access (the backend modules + # pull in converter.tts and its deps, which are only available inside + # the managed venv). Trigger the build so these tests don't depend on + # another test class having called detect_all() first. + get("audiocpp") + def test_registry_has_the_three_backends(self): keys = [info.key for info in REGISTRY] self.assertEqual(keys, ["audiocpp", "qwen", "faster"]) @@ -138,6 +145,7 @@ class ServerRunningTests(unittest.TestCase): def test_true_for_open_port(self): import socket + from backends import common server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind(("127.0.0.1", 0)) @@ -150,9 +158,10 @@ class ServerRunningTests(unittest.TestCase): server.close() def test_false_for_closed_port(self): - from backends import common # Pick an unused port by opening + closing a socket, then probe it. import socket + + from backends import common s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.bind(("127.0.0.1", 0)) _, port = s.getsockname() diff --git a/tests/test_backends_envs.py b/tests/test_backends_envs.py new file mode 100644 index 0000000..cf4ecc6 --- /dev/null +++ b/tests/test_backends_envs.py @@ -0,0 +1,204 @@ +"""Tests for the managed Python environment (backends/envs.py).""" + +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +from backends import envs + + +class EnvPathTests(unittest.TestCase): + """Platform-aware path helpers (no venv actually created).""" + + def test_env_dir_under_envs_tts(self): + self.assertEqual(envs.ENV_DIR.name, "tts") + self.assertEqual(envs.ENV_DIR.parent.name, "envs") + + def test_env_python_posix(self): + with patch.object(envs, "_is_windows", return_value=False): + self.assertEqual(envs.env_python(), + envs.ENV_DIR / "bin" / "python") + + def test_env_python_windows(self): + with patch.object(envs, "_is_windows", return_value=True): + self.assertEqual(envs.env_python(), + envs.ENV_DIR / "Scripts" / "python.exe") + + def test_env_script_posix(self): + with patch.object(envs, "_is_windows", return_value=False): + self.assertEqual(envs.env_script("qwen-tts-demo"), + envs.ENV_DIR / "bin" / "qwen-tts-demo") + + def test_env_script_windows(self): + with patch.object(envs, "_is_windows", return_value=True): + self.assertEqual(envs.env_script("qwen-tts-demo"), + envs.ENV_DIR / "Scripts" / "qwen-tts-demo.exe") + + def test_env_exists_false_when_python_missing(self): + with patch.object(envs, "env_python", + return_value=Path("/no/such/path/python")): + self.assertFalse(envs.env_exists()) + + def test_is_managed_env_compares_resolved_executable(self): + fake_env_python = Path("/tmp/opencode/managed-env/bin/python") + with patch.object(envs, "env_python", return_value=fake_env_python), \ + patch.object(sys, "executable", str(fake_env_python)): + self.assertTrue(envs.is_managed_env()) + with patch.object(envs, "env_python", return_value=fake_env_python), \ + patch.object(sys, "executable", "/usr/bin/python3"): + self.assertFalse(envs.is_managed_env()) + + +class CreateEnvTests(unittest.TestCase): + def test_create_env_invokes_venv_module(self): + with patch.object(envs.common, "run_console_subprocess", + return_value=0) as run: + rc = envs.create_env() + self.assertEqual(rc, 0) + argv = run.call_args[0][0] + self.assertEqual(argv[0], sys.executable) + self.assertEqual(argv[1], "-m") + self.assertEqual(argv[2], "venv") + self.assertEqual(argv[3], str(envs.ENV_DIR)) + + def test_create_env_reports_remediation_on_failure(self): + with patch.object(envs.common, "run_console_subprocess", + return_value=1): + rc = envs.create_env() + self.assertEqual(rc, 1) + + +class PipInstallTests(unittest.TestCase): + def test_creates_env_first_when_missing(self): + calls = [] + + def fake_run(argv): + calls.append(list(argv)) + return 0 + + with patch.object(envs, "env_exists", return_value=False), \ + patch.object(envs, "create_env", return_value=0) as mk, \ + patch.object(envs.common, "run_console_subprocess", + side_effect=fake_run): + rc = envs.pip_install(["qwen-tts"]) + self.assertEqual(rc, 0) + mk.assert_called_once_with() + # The actual pip call targets the venv's python. + self.assertEqual(calls[0][0], str(envs.env_python())) + self.assertIn("pip", calls[0]) + self.assertIn("qwen-tts", calls[0]) + + def test_skips_create_when_env_exists(self): + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "create_env") as mk, \ + patch.object(envs.common, "run_console_subprocess", + return_value=0): + envs.pip_install(["qwen-tts"]) + mk.assert_not_called() + + def test_returns_nonzero_when_create_fails(self): + with patch.object(envs, "env_exists", return_value=False), \ + patch.object(envs, "create_env", return_value=1), \ + patch.object(envs.common, "run_console_subprocess") as run: + rc = envs.pip_install(["qwen-tts"]) + self.assertEqual(rc, 1) + run.assert_not_called() + + +class ModuleAvailableTests(unittest.TestCase): + def test_false_when_env_missing(self): + with patch.object(envs, "env_exists", return_value=False): + self.assertFalse(envs.module_available("qwen_tts")) + + def test_true_when_subprocess_exits_zero(self): + import subprocess + fake = subprocess.CompletedProcess(args=["x"], returncode=0) + with patch.object(envs, "env_exists", return_value=True), \ + patch("subprocess.run", return_value=fake) as run: + self.assertTrue(envs.module_available("qwen_tts")) + argv = run.call_args[0][0] + self.assertEqual(argv[0], str(envs.env_python())) + self.assertIn("import qwen_tts", argv[2]) + + def test_false_when_subprocess_exits_nonzero(self): + import subprocess + fake = subprocess.CompletedProcess(args=["x"], returncode=1) + with patch.object(envs, "env_exists", return_value=True), \ + patch("subprocess.run", return_value=fake): + self.assertFalse(envs.module_available("qwen_tts")) + + def test_false_on_timeout(self): + import subprocess + with patch.object(envs, "env_exists", return_value=True), \ + patch("subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="x", timeout=1)): + self.assertFalse(envs.module_available("qwen_tts")) + + +class EnsureAppEnvTests(unittest.TestCase): + def test_creates_env_then_installs_when_marker_invalid(self): + with patch.object(envs, "env_exists", return_value=False), \ + patch.object(envs, "create_env", return_value=0), \ + patch.object(envs, "_marker_valid", return_value=False), \ + patch.object(envs, "install_requirements", return_value=0), \ + patch.object(envs, "_write_marker") as mk: + envs.ensure_app_env() + mk.assert_called_once_with() + + def test_raises_when_create_fails(self): + with patch.object(envs, "env_exists", return_value=False), \ + patch.object(envs, "create_env", return_value=1): + with self.assertRaises(RuntimeError): + envs.ensure_app_env() + + def test_raises_when_install_fails(self): + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "_marker_valid", return_value=False), \ + patch.object(envs, "install_requirements", return_value=1): + with self.assertRaises(RuntimeError): + envs.ensure_app_env() + + def test_skips_install_when_marker_valid(self): + with patch.object(envs, "env_exists", return_value=True), \ + patch.object(envs, "_marker_valid", return_value=True), \ + patch.object(envs, "install_requirements") as mk: + envs.ensure_app_env() + mk.assert_not_called() + + +class BootstrapTests(unittest.TestCase): + def test_noop_when_already_managed(self): + with patch.object(envs, "is_managed_env", return_value=True), \ + patch.object(envs, "ensure_app_env") as mk, \ + patch("os.execv") as ex: + envs.bootstrap("/path/to/audiobook.py") + mk.assert_not_called() + ex.assert_not_called() + + def test_ensures_env_then_execvs(self): + with patch.object(envs, "is_managed_env", return_value=False), \ + patch.object(envs, "ensure_app_env") as mk_env, \ + patch("os.execv") as ex, \ + patch.object(sys, "argv", ["audiobook.py", "--backend", "qwen"]): + envs.bootstrap("/path/to/audiobook.py") + mk_env.assert_called_once_with() + py = str(envs.env_python()) + args = ex.call_args[0] + self.assertEqual(args[0], py) + self.assertEqual(args[1][0], py) + self.assertTrue(args[1][1].endswith("audiobook.py")) + self.assertEqual(args[1][2:], ["--backend", "qwen"]) + + def test_exits_when_ensure_raises(self): + with patch.object(envs, "is_managed_env", return_value=False), \ + patch.object(envs, "ensure_app_env", + side_effect=RuntimeError("boom")), \ + patch("os.execv") as ex, \ + self.assertRaises(SystemExit): + envs.bootstrap("/path/to/audiobook.py") + ex.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_backends_servers.py b/tests/test_backends_servers.py new file mode 100644 index 0000000..02b65e6 --- /dev/null +++ b/tests/test_backends_servers.py @@ -0,0 +1,146 @@ +"""Tests for the server lifecycle module (backends/servers.py).""" + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from backends import ServerSpec, servers + + +class StartTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + # A fake executable so Path(argv[0]).exists() passes. + self.exe = self.dir / "fake_server" + self.exe.write_bytes(b"#!/bin/sh\n") + self.spec = ServerSpec("test", "http://127.0.0.1:9999", + [str(self.exe), "--port", "9999"]) + + def tearDown(self): + self._tmp.cleanup() + + def test_returns_false_when_executable_missing(self): + spec = ServerSpec("nope", "http://127.0.0.1:1", ["/no/such/binary"]) + with patch.object(servers, "LOG_DIR", self.dir): + self.assertFalse(servers.start(spec)) + + def test_noop_when_already_running(self): + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("backends.common.server_running", return_value=True), \ + patch("subprocess.Popen") as mk: + self.assertTrue(servers.start(self.spec)) + mk.assert_not_called() + + def test_happy_path_spawns_and_polls_until_ready(self): + proc = MagicMock() + proc.pid = 4242 + proc.poll.return_value = None # process still running + # server_running: False on the pre-check, True once inside the loop. + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc) as mk, \ + patch("backends.common.server_running", + side_effect=[False, True]), \ + patch("time.sleep"): + ok = servers.start(self.spec) + self.assertTrue(ok) + mk.assert_called_once() + # Pid file written. + self.assertEqual( + (self.dir / "test-server.pid").read_text(encoding="utf-8"), + "4242") + + def test_returns_false_when_process_exits_early(self): + proc = MagicMock() + proc.pid = 99 + proc.poll.return_value = 1 # exited with code 1 + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc), \ + patch("backends.common.server_running", return_value=False), \ + patch("time.sleep"): + ok = servers.start(self.spec) + self.assertFalse(ok) + # Pid file cleaned up after early exit. + self.assertFalse((self.dir / "test-server.pid").exists()) + + def test_returns_false_on_timeout(self): + proc = MagicMock() + proc.pid = 7 + proc.poll.return_value = None + # time.time: first call < deadline loop entry, then past deadline. + times = iter([0.0, float(servers.SERVER_START_TIMEOUT + 1)]) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc), \ + patch("backends.common.server_running", return_value=False), \ + patch("time.sleep"), \ + patch("time.time", side_effect=lambda: next(times)): + ok = servers.start(self.spec) + self.assertFalse(ok) + + +class StopTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _write_pid(self, name, pid): + (self.dir / f"{name}-server.pid").write_text(str(pid), + encoding="utf-8") + + def test_returns_false_when_no_pid_file(self): + with patch.object(servers, "LOG_DIR", self.dir): + self.assertFalse(servers.stop("test")) + + def test_stops_alive_process_and_removes_pid_file(self): + self._write_pid("test", 1234) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=True), \ + patch.object(servers, "_kill_pid", return_value=True) as mk: + ok = servers.stop("test") + self.assertTrue(ok) + mk.assert_called_once_with(1234) + self.assertFalse((self.dir / "test-server.pid").exists()) + + def test_already_dead_returns_true_and_cleans_pid_file(self): + self._write_pid("test", 1234) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch.object(servers, "_pid_alive", return_value=False), \ + patch.object(servers, "_kill_pid") as mk: + ok = servers.stop("test") + self.assertTrue(ok) + mk.assert_not_called() + self.assertFalse((self.dir / "test-server.pid").exists()) + + def test_corrupt_pid_file_returns_false_and_cleans(self): + (self.dir / "test-server.pid").write_text("not-a-number", + encoding="utf-8") + with patch.object(servers, "LOG_DIR", self.dir): + self.assertFalse(servers.stop("test")) + self.assertFalse((self.dir / "test-server.pid").exists()) + + +class PidForTests(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_none_when_no_pid_file(self): + with patch.object(servers, "LOG_DIR", self.dir): + self.assertIsNone(servers.pid_for("test")) + + def test_returns_pid_from_file(self): + (self.dir / "test-server.pid").write_text("555\n", + encoding="utf-8") + with patch.object(servers, "LOG_DIR", self.dir): + self.assertEqual(servers.pid_for("test"), 555) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hub.py b/tests/test_hub.py index ce9af43..21f795e 100644 --- a/tests/test_hub.py +++ b/tests/test_hub.py @@ -8,8 +8,9 @@ import unittest from pathlib import Path from unittest.mock import patch -from ui import hub, tui +from backends import BackendStatus, ServerSpec from tests.test_tui import FakeCurses, FakeScreen +from ui import hub, tui class HubHelperTests(unittest.TestCase): @@ -93,7 +94,7 @@ class HubMenuTests(unittest.TestCase): labels = [label for label, _ in captured["options"]] self.assertEqual(labels, ["Set up a backend...", "Quit"]) - def test_menu_has_all_four_when_one_installed(self): + def test_menu_has_all_five_when_one_installed(self): captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -111,7 +112,7 @@ class HubMenuTests(unittest.TestCase): self.assertEqual( labels, ["Convert books...", "Set up a backend...", - "Configure a backend...", "Quit"]) + "Configure a backend...", "Server...", "Quit"]) # The status table is passed through, one row per backend. self.assertEqual(captured["rows"], [("qwen-tts", "installed", "warn", "body")]) @@ -137,9 +138,9 @@ class HubMenuTests(unittest.TestCase): [("audio.cpp", "unavailable", "err", "dim"), ("qwen-tts", "running", "ok", "body")]) - def test_menu_has_all_four_when_one_running_only(self): + def test_menu_has_all_five_when_one_running_only(self): # Running but not installed (an external server) still unlocks the - # Convert/Configure entries. + # Convert/Configure/Server entries. captured = {} def fake_menu(stdscr, title, options, **kwargs): @@ -156,14 +157,14 @@ class HubMenuTests(unittest.TestCase): self.assertEqual( labels, ["Convert books...", "Set up a backend...", - "Configure a backend...", "Quit"]) + "Configure a backend...", "Server...", "Quit"]) def test_convert_with_no_available_backend_offers_setup(self): # One installed-but-not-ready backend → Convert is offered. The # convert menu lists no available backend, so only "Set up a # backend..." is shown; Enter selects it → setup menu lists 3 # backends; Esc goes back → convert returns None → main menu loops. - # Then quit: main menu now has 4 options, Quit is the 4th (Down x3). + # Then quit: main menu now has 5 options, Quit is the 5th (Down x4). from backends import BackendInfo, BackendStatus none = BackendStatus("k", "l", installed=True, configured=False) infos = [BackendInfo("audiocpp", "audio.cpp", lambda: none, @@ -181,13 +182,139 @@ class HubMenuTests(unittest.TestCase): with patch.object(hub, "detect_all", return_value=statuses), \ patch.object(hub, "REGISTRY", infos): # Convert(Enter), setup-entry(Enter), Esc on setup menu, - # back at main menu -> Down x3 -> Enter (Quit). + # back at main menu -> Down x4 -> Enter (Quit). screen = FakeScreen(keys=[10, 10, 27, FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, - FakeCurses.KEY_DOWN, 10]) + FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN, + 10]) result = hub._hub_menu(screen) self.assertIsNone(result) +class SelectSpecTests(unittest.TestCase): + """_select_spec: mode-aware server selection (qwen has two servers).""" + + def _qwen_status(self): + return BackendStatus( + "qwen", "qwen-tts", installed=True, configured=True, + servers=[ServerSpec("qwen-custom", "http://127.0.0.1:7860", []), + ServerSpec("qwen-clone", "http://127.0.0.1:7861", [])]) + + def test_qwen_custom_mode(self): + spec = hub._select_spec(self._qwen_status(), {"clone": None}) + self.assertEqual(spec.name, "qwen-custom") + + def test_qwen_clone_mode(self): + spec = hub._select_spec(self._qwen_status(), {"clone": "ref.wav"}) + self.assertEqual(spec.name, "qwen-clone") + + def test_audiocpp_returns_single_spec(self): + st = BackendStatus("audiocpp", "audio.cpp", installed=True, + configured=True, + servers=[ServerSpec("audiocpp", "http://x", [])]) + spec = hub._select_spec(st, {}) + self.assertEqual(spec.name, "audiocpp") + + def test_none_when_no_servers(self): + st = BackendStatus("qwen", "qwen-tts", installed=False, + configured=False) + self.assertIsNone(hub._select_spec(st, {})) + + +class RunConversionTests(unittest.TestCase): + """_run_conversion: autostart, hint-when-manual, and stop-after.""" + + def test_autostart_starts_server_then_converts(self): + spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, running=False, + servers=[spec]) + kwargs = {"autostart": "qwen-custom"} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch.object(hub, "_find_spec", return_value=spec), \ + patch.object(hub.servers, "start", return_value=True) as mk_start, \ + patch.object(hub.audiobook, "convert", return_value=0) as mk_conv, \ + patch("builtins.input", return_value="n") as mk_input, \ + patch.object(hub.servers, "stop") as mk_stop: + hub._run_conversion("qwen", kwargs) + mk_start.assert_called_once_with(spec) + mk_conv.assert_called_once() + # User declined stopping → stop not called. + mk_stop.assert_not_called() + + def test_autostart_stop_when_user_says_yes(self): + spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, running=False, + servers=[spec]) + kwargs = {"autostart": "qwen-custom"} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch.object(hub, "_find_spec", return_value=spec), \ + patch.object(hub.servers, "start", return_value=True), \ + patch.object(hub.audiobook, "convert", return_value=0), \ + patch("builtins.input", return_value="y"), \ + patch.object(hub.servers, "stop") as mk_stop: + hub._run_conversion("qwen", kwargs) + mk_stop.assert_called_once_with("qwen-custom") + + def test_autostart_aborts_when_server_fails(self): + spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, running=False, + launch_hint="hint cmd", servers=[spec]) + kwargs = {"autostart": "qwen-custom"} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch.object(hub, "_find_spec", return_value=spec), \ + patch.object(hub.servers, "start", return_value=False), \ + patch.object(hub.audiobook, "convert") as mk_conv, \ + patch.object(hub.servers, "stop") as mk_stop: + hub._run_conversion("qwen", kwargs) + mk_conv.assert_not_called() + mk_stop.assert_not_called() + + def test_no_autostart_prints_hint_when_not_running(self): + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, running=False, + launch_hint="the-hint") + with patch.object(hub, "detect_all", return_value=[status]), \ + patch.object(hub.audiobook, "convert", return_value=0) as mk_conv: + hub._run_conversion("qwen", {}) + mk_conv.assert_called_once() + + +class AddAutostartTests(unittest.TestCase): + """_add_autostart: offers to start the server when it isn't running.""" + + def setUp(self): + tui._THEME.clear() + self.curses = FakeCurses() + self._patcher = patch.dict("sys.modules", {"curses": self.curses}) + self._patcher.start() + self.addCleanup(self._patcher.stop) + self.addCleanup(tui._THEME.clear) + + def _status(self): + spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) + return BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, running=False, + servers=[spec]) + + def test_sets_autostart_when_user_confirms(self): + screen = FakeScreen(keys=[10]) # Enter = Yes + cmd = ("convert", "qwen", {"clone": None}) + with patch.object(hub, "detect_all", return_value=[self._status()]), \ + patch("backends.common.server_running", return_value=False): + hub._add_autostart(screen, cmd, [self._status()]) + self.assertEqual(cmd[2]["autostart"], "qwen-custom") + + def test_no_autostart_when_server_already_running(self): + screen = FakeScreen(keys=[10]) + cmd = ("convert", "qwen", {"clone": None}) + with patch.object(hub, "detect_all", return_value=[self._status()]), \ + patch("backends.common.server_running", return_value=True): + hub._add_autostart(screen, cmd, [self._status()]) + self.assertNotIn("autostart", cmd[2]) + + if __name__ == "__main__": unittest.main() @@ -16,15 +16,27 @@ import json from pathlib import Path from typing import Optional, Tuple -from ui import tui import audiobook -from backends import REGISTRY, BackendStatus, detect_all, get +from backends import ( + REGISTRY, + BackendStatus, + ServerSpec, + common, + detect_all, + get, + servers, +) from backends import audiocpp as audiocpp_backend from backends import faster as faster_backend from converter import config from converter.converter import AUDIO_FORMATS -from converter.tts import AUDIOCPP_FAMILY_QWEN3_TTS, BACKEND_AUDIOCPP, \ - BACKEND_FASTER, BACKEND_QWEN +from converter.tts import ( + AUDIOCPP_FAMILY_QWEN3_TTS, + BACKEND_AUDIOCPP, + BACKEND_FASTER, + BACKEND_QWEN, +) +from ui import tui _GO_BACK = object() @@ -54,6 +66,8 @@ def run() -> int: info.configure_actions[command[2]].run() elif kind == "convert": _run_conversion(command[1], command[2]) + elif kind == "server": + _run_server_action(command[1], command[2]) def _hub_menu(stdscr) -> Optional[tuple]: @@ -64,6 +78,7 @@ def _hub_menu(stdscr) -> Optional[tuple]: if any(st.installed or st.running for st in statuses): options.insert(0, ("Convert books...", "convert")) options.append(("Configure a backend...", "configure")) + options.append(("Server...", "server")) options.append(("Quit", "quit")) rows = [(st.label, *_status_mark(st)) for st in statuses] choice = tui.menu( @@ -83,6 +98,10 @@ def _hub_menu(stdscr) -> Optional[tuple]: cmd = _configure_menu(stdscr, statuses) if cmd is not None: return cmd + elif choice == "server": + cmd = _server_menu(stdscr, statuses) + if cmd is not None: + return cmd def _setup_menu(stdscr, statuses) -> Optional[tuple]: @@ -162,12 +181,17 @@ def _convert_menu(stdscr, statuses) -> Optional[tuple]: if key == "__setup__": return _setup_menu(stdscr, statuses) if key == BACKEND_AUDIOCPP: - return _convert_audiocpp(stdscr, statuses) - if key == BACKEND_QWEN: - return _convert_qwen(stdscr) - if key == BACKEND_FASTER: - return _convert_faster(stdscr) - return None + cmd = _convert_audiocpp(stdscr, statuses) + elif key == BACKEND_QWEN: + cmd = _convert_qwen(stdscr) + elif key == BACKEND_FASTER: + cmd = _convert_faster(stdscr) + else: + return None + if cmd is None: + return None + _add_autostart(stdscr, cmd, statuses) + return cmd def _convert_audiocpp(stdscr, statuses) -> Optional[tuple]: @@ -350,14 +374,141 @@ def _common_options(stdscr) -> Optional[dict]: def _run_conversion(backend: str, kwargs: dict) -> None: - """Run a conversion in the plain console (after the TUI returns).""" + """Run a conversion in the plain console (after the TUI returns). + + When the convert menu recorded an ``autostart`` server (the user opted to + have the hub start it), spawn it now and abort the conversion if it does + not come up. After the conversion, offer to stop a server we started. + """ + autostart = kwargs.pop("autostart", None) status = next((s for s in detect_all() if s.key == backend), None) if status is not None and not status.ready and not status.running: print(f"[WARNING] {status.label} is not fully set up.") - if status is not None and not status.running and status.launch_hint: + if autostart: + spec = _find_spec(autostart) + if spec is None: + print(f"[WARNING] no server named '{autostart}'; continuing") + elif not servers.start(spec): + print("[ERROR] could not start the server; aborting conversion.") + if status is not None and status.launch_hint: + print("Start it manually and run the conversion again:") + print(f" {status.launch_hint}") + return + elif status is not None and not status.running and status.launch_hint: print("[INFO] Make sure the server is running. Start it with:") print(f" {status.launch_hint}") - audiobook.convert(backend=backend, **kwargs) + try: + audiobook.convert(backend=backend, **kwargs) + finally: + if autostart: + _maybe_stop_server(autostart) + + +def _maybe_stop_server(name: str) -> None: + """Ask (in the plain console) whether to stop a server we auto-started.""" + try: + ans = input(f"\n[?] Stop the '{name}' server now? [y/N] ").strip().lower() + except EOFError: + return + if ans in ("y", "yes"): + servers.stop(name) + + +def _add_autostart(stdscr, cmd: tuple, statuses) -> None: + """Offer to auto-start the conversion's target server when it isn't running. + + Records the chosen server spec name as ``kwargs['autostart']`` for + ``_run_conversion`` to act on. Mode-aware for qwen (custom vs clone). + """ + _, key, kwargs = cmd + status = next((s for s in statuses if s.key == key), None) + if status is None or not status.servers: + return + spec = _select_spec(status, kwargs) + if spec is None: + return + if common.server_running(spec.url): + return + choice = tui.confirm(stdscr, f"The {status.label} server is not running. " + "Start it automatically?", default=True, + cancel_value=False) + if choice is True: + kwargs["autostart"] = spec.name + + +def _select_spec(status, kwargs) -> Optional[ServerSpec]: + """The server spec this conversion needs (mode-aware for qwen).""" + if status.key == BACKEND_QWEN: + wanted = "qwen-clone" if kwargs.get("clone") else "qwen-custom" + return next((s for s in status.servers if s.name == wanted), None) + return status.servers[0] if status.servers else None + + +def _find_spec(name: str) -> Optional[ServerSpec]: + """Look up a server spec by name across every backend's detect().""" + for st in detect_all(): + for spec in st.servers: + if spec.name == name: + return spec + return None + + +def _run_server_action(spec_name: str, action: str) -> None: + """Run a Start/Stop action in the plain console (after the TUI returns).""" + if action == "start": + spec = _find_spec(spec_name) + if spec is None: + print(f"[ERROR] no server named '{spec_name}'") + return + servers.start(spec) + elif action == "stop": + servers.stop(spec_name) + + +def _server_menu(stdscr, statuses) -> Optional[tuple]: + """Pick a backend, then one of its servers and a Start/Stop action.""" + candidates = [st for st in statuses if st.servers or st.running] + if not candidates: + tui.flash(stdscr, "No backend with a server is available. " + "Set one up first.") + return None + options = [(st.label, st.key) for st in candidates] + key = tui.menu(stdscr, "Start / Stop a server", options, + back_value=_GO_BACK) + if key is _GO_BACK or key is None: + return None + status = next((s for s in statuses if s.key == key), None) + if status is None: + return None + return _server_actions(stdscr, status) + + +def _server_actions(stdscr, status) -> Optional[tuple]: + """Pick a server spec (qwen has two) and a Start or Stop action.""" + specs = status.servers + if not specs: + tui.flash(stdscr, f"{status.label} has no server configured. " + "Run 'Set up a backend' first.") + return None + if len(specs) == 1: + spec = specs[0] + else: + options = [(f"{s.name} ({'running' if common.server_running(s.url) else 'stopped'})", + s.name) for s in specs] + name = tui.menu(stdscr, f"{status.label} server", options, + back_value=_GO_BACK) + if name is _GO_BACK or name is None: + return None + spec = next((s for s in specs if s.name == name), None) + if spec is None: + return None + running = common.server_running(spec.url) + action = tui.menu( + stdscr, f"{spec.name} ({'running' if running else 'stopped'})", + [("Start", "start"), ("Stop", "stop")], back_value=_GO_BACK) + if action is _GO_BACK or action is None: + return None + return ("server", spec.name, action) def _list_voices(voice_dir: str) -> list: diff --git a/voices/.gitkeep b/voices/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/voices/.gitkeep |
