aboutsummaryrefslogtreecommitdiff
path: root/app/backends
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends')
-rw-r--r--app/backends/__init__.py12
-rw-r--r--app/backends/probe.py7
-rw-r--r--app/backends/qwen.py148
3 files changed, 102 insertions, 65 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index 2b7fbe6..192067c 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -41,8 +41,8 @@ _detect_cache = None
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
+ Most backends expose exactly one server. 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.
@@ -88,14 +88,14 @@ class BackendStatus:
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
+ name ("audiocpp", "faster", "qwen") 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.
+ RUNNING_MODELS names which of a multi-model backend's models answered
+ (qwen: "Base", "CustomVoice" and/or "VoiceDesign", local and remote
+ combined), shown in parentheses in the hub's status table.
MODELS_MISSING says the server config references model files that are not
on disk (e.g. an audio.cpp server.json entry whose ``path`` was never
diff --git a/app/backends/probe.py b/app/backends/probe.py
index e996725..64dc7ab 100644
--- a/app/backends/probe.py
+++ b/app/backends/probe.py
@@ -14,7 +14,8 @@ identity check over plain HTTP:
the endpoint names the converter calls (``/run_instruct`` /
``/run_custom_voice`` / ``/generate_custom_voice`` for the
CustomVoice demo; ``/run_voice_clone`` / ``/generate_voice_clone``
- for the Base demo).
+ for the Base demo; ``/run_voice_design`` for the VoiceDesign
+ demo).
``identify_server`` returns one of the IDENTITY_* constants, or None when the
URL does not answer or answers as something unrecognized. It is stdlib-only
@@ -33,6 +34,7 @@ IDENTITY_AUDIOCPP = "audiocpp"
IDENTITY_FASTER = "faster"
IDENTITY_QWEN_CUSTOM = "qwen-custom"
IDENTITY_QWEN_CLONE = "qwen-clone"
+IDENTITY_QWEN_DESIGN = "qwen-design"
# Endpoint names the converter resolves for each qwen demo server (see
# converter.clients QwenTTSClient). Mirror them here so identification matches
@@ -40,6 +42,7 @@ IDENTITY_QWEN_CLONE = "qwen-clone"
_QWEN_CUSTOM_ENDPOINTS = (
"/run_instruct", "/run_custom_voice", "/generate_custom_voice")
_QWEN_CLONE_ENDPOINTS = ("/run_voice_clone", "/generate_voice_clone")
+_QWEN_DESIGN_ENDPOINTS = ("/run_voice_design",)
DEFAULT_TIMEOUT = 3.0
@@ -105,6 +108,8 @@ def _identify_gradio(base: str, timeout: float) -> Optional[str]:
return IDENTITY_QWEN_CUSTOM
if any(name in endpoints for name in _QWEN_CLONE_ENDPOINTS):
return IDENTITY_QWEN_CLONE
+ if any(name in endpoints for name in _QWEN_DESIGN_ENDPOINTS):
+ return IDENTITY_QWEN_DESIGN
return None
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)