From 477ac3e827e3bdc9f14583fc3aa8db1fa2d27c52 Mon Sep 17 00:00:00 2001 From: historia Date: Wed, 26 Aug 2026 21:22:40 -0400 Subject: feat: design model support for qwen-tts backend. remove unnecessary port split for qwen models --- app/backends/qwen.py | 148 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 90 insertions(+), 58 deletions(-) (limited to 'app/backends/qwen.py') 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) -- cgit v1.2.3