aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-24 01:57:13 -0400
committerhistoria <historiavg@proton.me>2026-08-24 01:58:17 -0400
commitc02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a (patch)
treee9ec4f35c18102d4624d3cd59358d192be7bbfcb
parent194c63e4d11e6de9792a736a7b99788f1db78741 (diff)
downloadtts-audiobook-generator-c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a.tar.gz
feat: manage venv for all backends
-rw-r--r--.gitignore9
-rw-r--r--README.md124
-rwxr-xr-xaudiobook.py11
-rw-r--r--backends/__init__.py41
-rwxr-xr-xbackends/audiocpp.py46
-rw-r--r--backends/common.py19
-rw-r--r--backends/envs.py185
-rwxr-xr-xbackends/faster.py63
-rw-r--r--backends/qwen.py41
-rw-r--r--backends/servers.py244
-rw-r--r--docs/backend-audiocpp.md91
-rw-r--r--docs/backend-faster.md8
-rw-r--r--docs/backend-qwen.md6
-rw-r--r--requirements.txt2
-rw-r--r--tests/test_backends.py11
-rw-r--r--tests/test_backends_envs.py204
-rw-r--r--tests/test_backends_servers.py146
-rw-r--r--tests/test_hub.py145
-rw-r--r--ui/hub.py177
-rw-r--r--voices/.gitkeep0
20 files changed, 1380 insertions, 193 deletions
diff --git a/.gitignore b/.gitignore
index 1b243f5..ca8252f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index 6eaf8a5..3f61655 100644
--- a/README.md
+++ b/README.md
@@ -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()
diff --git a/ui/hub.py b/ui/hub.py
index 3115f54..f3e2b7e 100644
--- a/ui/hub.py
+++ b/ui/hub.py
@@ -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