"""Registry of the TTS backends the audiobook generator can talk to. Each backend (audio.cpp, qwen, faster) lives in its own module and owns 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 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/.py`` exposing ``detect() -> BackendStatus``, ``run_tui() -> int`` and ``configure_actions: list[ConfigureAction]``, then append a ``BackendInfo`` in ``_build_registry`` below. ``audiobook.py`` and the hub pick it up 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. INSTALLED means the backend itself is present (a cloned + built checkout, or a pip package). CONFIGURED means the supporting files are in place (a server.json / voices.json and a converter/config.py that 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 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 installed: bool configured: bool 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: """True when the backend is installed and configured for use.""" 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").""" label: str run: Callable[[], int] @dataclass class BackendInfo: """One registry entry: identity, detector, setup wizard, configure menu.""" key: str label: str detect: Callable[[], BackendStatus] setup_tui: Callable[[], int] configure_actions: List[ConfigureAction] = field(default_factory=list) REGISTRY: List[BackendInfo] = [] _BY_KEY: dict = {} def _build_registry() -> None: """Import the backend modules and wire up REGISTRY (once).""" if REGISTRY: return from . import audiocpp, faster, qwen REGISTRY.append(BackendInfo( key="audiocpp", label="audio.cpp", detect=audiocpp.detect, setup_tui=audiocpp.run_tui, configure_actions=audiocpp.configure_actions, )) REGISTRY.append(BackendInfo( key="qwen", label="qwen-tts", detect=qwen.detect, setup_tui=qwen.run_tui, configure_actions=qwen.configure_actions, )) REGISTRY.append(BackendInfo( key="faster", label="faster-qwen3-tts", detect=faster.detect, setup_tui=faster.run_tui, configure_actions=faster.configure_actions, )) for info in REGISTRY: _BY_KEY[info.key] = info def get(key: str) -> Optional[BackendInfo]: """Return the registry entry for KEY, or None.""" _build_registry() return _BY_KEY.get(key) def detect_all() -> List[BackendStatus]: """Detect every registered backend's status, in registry order.""" _build_registry() return [info.detect() for info in REGISTRY] 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