1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
|
"""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/<name>.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, Dict, 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 an app/converter/config.py that
points at the right port). 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).
MANAGED says a running server was started by this tool: ``servers``
contains a spec whose pid file still names a live process (see
``servers.manages``); when a server is running but not MANAGED the hub
tags it "[remote]".
REMOTE says a server answering at the backend's configured remote URL
(``*_REMOTE_URL`` in ``app/converter/config.py``) was identified as this
backend by ``backends.probe.identify_server`` — a server this tool did
not start (it is suppressed when the remote URL equals the local URL and
this tool's own pid is still alive). REMOTE_URLS maps each server spec
name ("audiocpp", "faster", "qwen-custom", "qwen-clone") to the remote
URL that answered, so the hub's convert menu can target it. RUNNING is
true when the backend is usable either locally (MANAGED) or remotely
(REMOTE), and drives both the status table ("running [local]",
"running [remote]", "running [local, remote]") and the hub menu gating.
RUNNING_MODELS names which of a multi-server backend's models answered
(qwen: "Base" and/or "CustomVoice", local and remote combined), shown in
parentheses in the hub's status table.
"""
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)
managed: bool = False
running_models: List[str] = field(default_factory=list)
remote: bool = False
remote_urls: Dict[str, str] = field(default_factory=dict)
remote_models: List[str] = 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
|