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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
|
#!/usr/bin/env python3
"""Set up the Qwen3-TTS demo backend for the audiobook generator.
qwen-tts is a pip package providing the ``qwen-tts-demo`` server, which hosts
ONE Qwen3-TTS model per process — CustomVoice (built-in speakers), Base
(voice cloning) or VoiceDesign (described voice). This module sets it up
end-to-end: pip-install the package into the managed venv. There are no
questions to ask — the port and which model to run live in
``app/converter/config.py`` (the model is chosen per run on the hub's
Generate-audiobooks screen), and only one server runs at a time. It is driven
by ``audiobook.py``'s hub but can also be run directly:
Usage:
python app/backends/qwen.py [--skip-install]
"""
import argparse
import sys
from pathlib import Path
from typing import List, Optional
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from backends import (
BackendStatus,
ServerSpec,
common,
envs,
format_launch_hint,
probe,
servers,
setup,
)
from converter import config
from converter.clients import QWEN3_TTS_SPEAKERS
from ui import taskview
QWEN_PIP_PKG = "qwen-tts"
DEFAULT_PORT = 7860
# The models a single demo server can host, by config.QWEN_MODEL name.
# A running server identifies itself via its probe identity (backends.probe),
# so "which model is up" is always read off the server, never assumed.
MODEL_REPOS = {
"CustomVoice": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
"Base": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"VoiceDesign": "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
}
# Probe identity -> the model name reported in statuses/menus.
IDENTITY_TO_MODEL = {
probe.IDENTITY_QWEN_CUSTOM: "CustomVoice",
probe.IDENTITY_QWEN_CLONE: "Base",
probe.IDENTITY_QWEN_DESIGN: "VoiceDesign",
}
DEFAULT_MODEL = "CustomVoice"
# Built-in CustomVoice speakers (see app/converter/config.py SPEAKER). The
# canonical list lives in converter.clients.speakers (shared with the
# audio.cpp backend's Convert-form Speaker picker).
QWEN_SPEAKERS = QWEN3_TTS_SPEAKERS
def _is_installed() -> bool:
if envs.env_script("qwen-tts-demo").is_file():
return True
return envs.module_available("qwen_tts")
def _config_port(url: str, fallback: int) -> int:
import urllib.parse
try:
return urllib.parse.urlsplit(url).port or fallback
except ValueError:
return fallback
def current_model() -> str:
"""The configured model to host (a MODEL_REPOS key; DEFAULT_MODEL on typos)."""
return config.QWEN_MODEL if config.QWEN_MODEL in MODEL_REPOS else DEFAULT_MODEL
def model_for_identity(identity: Optional[str]) -> Optional[str]:
"""The model name a qwen demo answers as (None when not a known identity)."""
return IDENTITY_TO_MODEL.get(identity)
def desired_identity(model: str) -> str:
"""The probe identity the model's demo answers as (used while booting)."""
return {
"CustomVoice": probe.IDENTITY_QWEN_CUSTOM,
"Base": probe.IDENTITY_QWEN_CLONE,
"VoiceDesign": probe.IDENTITY_QWEN_DESIGN,
}[model]
def _wizard(stdscr, args: argparse.Namespace) -> dict:
"""Collect the setup settings without asking anything.
The qwen backend has no per-install choices: install happens when the
package is missing (and not skipped by flag), and every other value —
port, speaker, which model runs — lives in app/converter/config.py /
the hub's Settings and Generate-audiobooks screens.
"""
return {
"do_install": (not _is_installed()) and not args.skip_install,
}
def _execute_steps(settings: dict) -> List[taskview.TaskStep]:
"""Build the ordered setup steps for the in-TUI task view.
The same work ``_execute`` runs on the console. The pip install streams
through EMIT and aborts on CANCEL; the list is empty (no-op) when
there is nothing to install.
"""
steps: List[taskview.TaskStep] = []
if not settings["do_install"]:
return steps
def install(emit, cancel):
rc = common.pip_install([QWEN_PIP_PKG], emit=emit, cancel=cancel)
if rc != 0:
print(f"[WARNING] pip install failed (exit {rc}); install "
f"{QWEN_PIP_PKG} manually")
else:
print(f"[OK] {QWEN_PIP_PKG} installed")
return rc
steps.append(taskview.TaskStep(f"Install {QWEN_PIP_PKG}", install))
return steps
def _execute(settings: dict) -> int:
"""Console tail: pip install (no-op when already installed)."""
return taskview.run_steps_inline(_execute_steps(settings))
def setup_screen(stdscr) -> int:
"""Run the setup on an existing curses screen (the hub's).
There are no questions: settings are computed up front and the install
runs inside the TUI task view on this same screen — skipped entirely
(a silent no-op) when nothing needs installing. Returns 0 always —
the flow cannot be aborted, so Esc/Ctrl-C never short-circuits it.
"""
args = build_parser().parse_args([])
settings = _wizard(stdscr, args)
if not settings["do_install"]:
return 0
return taskview.run_steps(stdscr, "Setting up qwen-tts",
_execute_steps(settings))
def run_tui(args: Optional[argparse.Namespace] = None) -> int:
"""Run the qwen setup end-to-end."""
if args is None:
args = build_parser().parse_args([])
return _execute(_wizard(None, args))
def _collect_from_flags(args: argparse.Namespace,
parser: argparse.ArgumentParser) -> dict:
return {
"do_install": (not _is_installed()) and not args.skip_install,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Set up the Qwen3-TTS demo backend: pip install "
"qwen-tts into the managed venv.")
parser.add_argument("--skip-install", action="store_true",
help="Do not pip install qwen-tts")
return parser
def detect() -> BackendStatus:
"""Detect whether qwen-tts is installed, plus the launch command.
One managed spec exists, hosting ``config.QWEN_MODEL`` on the single
configured port. Which model currently answers there is read via the
probe (local pid alive => check our own URL; otherwise the remote URL)
so the status names the *running* model even when it differs from the
configured one.
"""
installed = _is_installed()
model = current_model()
url = config.QWEN_API_URL
details: List[str] = []
details.append("pip: installed" if installed else
"not installed — run setup to pip install qwen-tts")
details.append(f"port: {_config_port(url, DEFAULT_PORT)}")
details.append(f"model: {model}")
details.append(f"speaker: {config.SPEAKER}")
demo = str(envs.env_script("qwen-tts-demo"))
specs = [
ServerSpec("qwen", url,
[demo, MODEL_REPOS[model], "--ip", "127.0.0.1",
"--port", str(_config_port(url, DEFAULT_PORT))],
identity=desired_identity(model)),
]
managed = servers.manages(specs)
# A locally-managed server names its running model via the probe of the
# managed URL; a remotely-run demo names it via the remote-URL probe.
local_models: List[str] = []
if managed and servers.alive(specs[0].name):
found = model_for_identity(probe.identify_server(url))
if found is not None:
local_models.append(found)
remote_models, remote_urls = _detect_remote(managed)
running_models = list(dict.fromkeys(local_models + remote_models))
return BackendStatus("qwen", "qwen-tts",
installed=installed, configured=installed,
running=managed or bool(remote_urls),
details=details,
launch_hint=format_launch_hint(specs),
servers=specs,
managed=managed,
remote=bool(remote_urls),
remote_urls=remote_urls,
remote_models=remote_models,
running_models=running_models)
def _detect_remote(managed: bool = False):
"""Detect an externally-run qwen demo server at the remote URL.
Returns ``([model, ...], {spec_name: url})``. The remote URL must answer
as one of the three demos (see probe.identify_server); when it equals
the local URL and this tool started that server, it is ignored (already
reported "[local]").
"""
remote_models = []
remote_urls = {}
url = (config.QWEN_REMOTE_URL or "").strip()
if not url:
return remote_models, remote_urls
if managed and probe.same_endpoint(url, config.QWEN_API_URL):
return remote_models, remote_urls
model = model_for_identity(probe.identify_server(url))
if model is not None:
remote_urls["qwen"] = url
remote_models.append(model)
return remote_models, remote_urls
def uninstall(*, emit=None, cancel=None) -> int:
"""Remove the qwen-tts backend entirely: stop its server, pip uninstall.
qwen-tts is a pip package (``qwen_tts`` + the ``qwen-tts-demo`` script)
installed into the managed venv, so uninstalling it removes the backend.
Any server this tool started is stopped first (best-effort). Model
weights already fetched into the HuggingFace cache stay on disk.
With EMIT given (the in-TUI task view) pip runs piped, streaming into
EMIT, so its output never touches the terminal behind curses. CANCEL is
a ``threading.Event`` honored between phases only (after the server has
been stopped, before pip starts) — a started phase always completes,
so pip is never killed mid-run. Returns the exit code (130 when
cancelled before pip ran).
"""
if servers.pid_for("qwen") is not None:
# Only stop when a pid file exists: without one this tool never
# started the server, so the "not started by this tool" notice
# would be uninstall-time noise.
servers.stop("qwen")
if common.cancel_requested(cancel):
return 130
rc = common.pip_uninstall([QWEN_PIP_PKG], emit=emit)
if rc != 0:
print(f"[WARNING] pip uninstall failed (exit {rc}); remove "
f"{QWEN_PIP_PKG} from the managed venv manually")
else:
print(f"[OK] {QWEN_PIP_PKG} removed.")
return rc
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if setup.interactive():
return run_tui(args)
settings = _collect_from_flags(args, parser)
return _execute(settings)
if __name__ == "__main__":
sys.exit(main())
|