aboutsummaryrefslogtreecommitdiff
path: root/app/backends/qwen.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/qwen.py')
-rw-r--r--app/backends/qwen.py148
1 files changed, 90 insertions, 58 deletions
diff --git a/app/backends/qwen.py b/app/backends/qwen.py
index 592b0eb..c0c2cd1 100644
--- a/app/backends/qwen.py
+++ b/app/backends/qwen.py
@@ -1,14 +1,14 @@
#!/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 the Qwen3-TTS CustomVoice (built-in speakers) and Base (voice
-cloning) models on separate ports. This module sets it up end-to-end:
-pip-install the package into the managed venv. There are no questions to
-ask — the ports live in ``app/converter/config.py`` (edit them in the
-hub's Settings screen) and the speaker is chosen per run on the
-Generate-audiobooks screen. It is driven by ``audiobook.py``'s hub but
-can also be run directly:
+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]
@@ -36,10 +36,23 @@ from converter.clients import QWEN3_TTS_SPEAKERS
from ui import taskview
QWEN_PIP_PKG = "qwen-tts"
-QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
-QWEN_BASE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
-DEFAULT_CUSTOM_PORT = 7860
-DEFAULT_CLONE_PORT = 7861
+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
@@ -61,13 +74,32 @@ def _config_port(url: str, fallback: int) -> int:
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 —
- ports, speaker — lives in app/converter/config.py, managed from the
- hub's Settings and Generate-audiobooks screens.
+ 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,
@@ -143,33 +175,38 @@ def build_parser() -> argparse.ArgumentParser:
def detect() -> BackendStatus:
- """Detect whether qwen-tts is installed, plus the launch commands."""
+ """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()
- custom_port = _config_port(config.QWEN_API_URL, DEFAULT_CUSTOM_PORT)
- clone_port = _config_port(config.CLONE_API_URL, DEFAULT_CLONE_PORT)
+ 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"CustomVoice port: {custom_port}")
- details.append(f"Base (clone) port: {clone_port}")
+ 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-custom", config.QWEN_API_URL,
- [demo, QWEN_CUSTOMVOICE_MODEL, "--ip", "127.0.0.1",
- "--port", str(custom_port)],
- identity=probe.IDENTITY_QWEN_CUSTOM),
- ServerSpec("qwen-clone", config.CLONE_API_URL,
- [demo, QWEN_BASE_MODEL, "--ip", "127.0.0.1",
- "--port", str(clone_port)],
- identity=probe.IDENTITY_QWEN_CLONE),
+ 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)
- # Which local servers this tool started (pid alive) name the running
- # models; a remotely-run demo names them via the probe instead.
- local_models = [name for name, spec in
- (("Base", specs[1]), ("CustomVoice", specs[0]))
- if servers.alive(spec.name)]
+ # 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",
@@ -186,52 +223,47 @@ def detect() -> BackendStatus:
def _detect_remote(managed: bool = False):
- """Detect externally-run qwen demo servers at the remote URLs.
+ """Detect an externally-run qwen demo server at the remote URL.
- Returns ``([model, ...], {spec_name: url})``. Each remote URL (CustomVoice
- and Base) is probed independently and must answer as the matching demo
- (see probe.identify_server); a remote URL equal to the local URL for a
- server this tool started is ignored (already reported "[local]").
+ 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 = {}
- for spec_name, url, local_url, identity in (
- ("qwen-clone", config.CLONE_REMOTE_URL, config.CLONE_API_URL,
- probe.IDENTITY_QWEN_CLONE),
- ("qwen-custom", config.QWEN_REMOTE_URL, config.QWEN_API_URL,
- probe.IDENTITY_QWEN_CUSTOM)):
- url = (url or "").strip()
- if not url:
- continue
- if managed and probe.same_endpoint(url, local_url):
- continue
- if probe.identify_server(url) == identity:
- remote_urls[spec_name] = url
- remote_models.append(
- "Base" if spec_name == "qwen-clone" else "CustomVoice")
+ 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 servers, pip uninstall.
+ """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).
+ 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 servers
- have been stopped, before pip starts) — a started phase always completes,
+ 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).
"""
- for name in ("qwen-custom", "qwen-clone"):
+ 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.
- if servers.pid_for(name) is not None:
- servers.stop(name)
+ servers.stop("qwen")
if common.cancel_requested(cancel):
return 130
rc = common.pip_uninstall([QWEN_PIP_PKG], emit=emit)