aboutsummaryrefslogtreecommitdiff
path: root/backends
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 /backends
parent194c63e4d11e6de9792a736a7b99788f1db78741 (diff)
downloadtts-audiobook-generator-c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a.tar.gz
feat: manage venv for all backends
Diffstat (limited to 'backends')
-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
7 files changed, 576 insertions, 63 deletions
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