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 --- README.md | 6 +- app/backends/__init__.py | 12 +- app/backends/probe.py | 7 +- app/backends/qwen.py | 148 ++++++++++++++---------- app/converter/clients/__init__.py | 4 +- app/converter/clients/base.py | 10 +- app/converter/clients/qwen.py | 54 +++++++-- app/converter/config.py | 34 ++++-- app/converter/converter.py | 35 ++++-- app/docs/backend-qwen.md | 25 ++++- app/tests/test_backends.py | 80 +++++++------ app/tests/test_backends_probe.py | 7 ++ app/tests/test_converter_progress.py | 13 +++ app/tests/test_hub.py | 188 ++++++++++++++++++++++--------- app/tests/test_runview.py | 39 +++++++ app/tests/test_tts.py | 102 ++++++++++++++++- app/ui/hub.py | 211 +++++++++++++++++++++++------------ app/ui/runview.py | 24 ++-- audiobook.py | 33 ++++-- 19 files changed, 739 insertions(+), 293 deletions(-) diff --git a/README.md b/README.md index 31495a3..775b9db 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The converter sends text extracted from your books to a locally running TTS serv | Backend | Description | | -------------------------------------------------------------------- | ------------------------------------------------------ | | [audio.cpp](https://github.com/0xShug0/audio.cpp) | Newer C++ TTS backend that supports many recent models | -| [Qwen-TTS](https://pypi.org/project/qwen-tts/) | Qwen demo server (qwen-tts-demo) | +| [Qwen-TTS](https://pypi.org/project/qwen-tts/) | Qwen demo server (qwen-tts-demo); one model at a time — CustomVoice, Base or VoiceDesign | | [Faster-Qwen-TTS](https://github.com/andimarafioti/faster-qwen3-tts) | Qwen server with 2-8x faster inference for NVidia GPUs | ## Prerequisites @@ -67,7 +67,7 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- | `--language ` | Output language for the synthesized speech. Can add an accent even if the text is English. | | `--debug` | Dump each chunk's raw audio and sent text to `debug/` and log every request. | | `--model ` | `audiocpp`: Choose the model from `server.json` | -| `--instructions "..."` | `audiocpp`: voice design or style instruction. Required for voice design models (`vdes`) | +| `--instructions "..."` | `audiocpp`: voice design or style instruction (required for voice design models, task `vdes`). `qwen`: selects the VoiceDesign model and describes the voice to synthesize with. | | `--option KEY=VALUE` | `audiocpp`: Some models support custom options (e.g. `emotion=netural`) that can be passed with this flag | | `--voice ` | `audiocpp`, `faster`: Voice to request. `audiocpp`: on the Qwen3-TTS CustomVoice entry a built-in speaker (e.g. `Vivian`, `Ryan`, `Uncle_Fu`); on every other family a `voice_preset`/`voice_dir` entry for cloning (with `AUDIOCPP_CLONE_MODEL_ID` set it reroutes to the clone model — typically the Qwen Base model). `faster`: a key in the server's `voices.json`. | | `--clone ` | `qwen`: Reference audio (`wav`) for voice cloning. | @@ -77,7 +77,7 @@ Everything the TUI does can also be scripted with flags: `python audiobook.py -- Other options — including backend server URLs, ports, and the remote-server URLs the hub probes for `[remote]` entries — are configured in `app/converter/config.py` (or the TUI's **Settings** menu). -The **Generate audiobooks** TUI form exposes the same per-run controls as these flags: for `audiocpp` the Model picker labels each entry's voice capability (`speaker` / `clone` / `design`), the Voice field is labelled **Built-in voice** on CustomVoice entries and **Voice to clone** everywhere else, Instructions work on every entry (required for `vdes`, optional style/delivery control elsewhere — or the voice itself on families without built-in speakers), a Request options field accepts `KEY=VALUE` items (the `--option` equivalent, shown only for model families whose audio.cpp spec declares request options), and Language overrides the global setting per run (hidden for `faster`, which owns language server-side). The Instructions and Request options editors show dim hints with examples while editing. +The **Generate audiobooks** TUI form exposes the same per-run controls as these flags: for `audiocpp` the Model picker labels each entry's voice capability (`speaker` / `clone` / `design`), the Voice field is labelled **Built-in voice** on CustomVoice entries and **Voice to clone** everywhere else, Instructions work on every entry (required for `vdes`, optional style/delivery control elsewhere — or the voice itself on families without built-in speakers), a Request options field accepts `KEY=VALUE` items (the `--option` equivalent, shown only for model families whose audio.cpp spec declares request options), and Language overrides the global setting per run (hidden for `faster`, which owns language server-side). For `qwen-tts` a single **Model** picker selects which demo the server hosts — Base (voice cloning), CustomVoice (built-in voices) or VoiceDesign (design) — with the matching controls shown below it; the choice is remembered, and switching models restarts the managed server on its single port. The Instructions and Request options editors show dim hints with examples while editing. ## Manual TTS Backend Setup 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) diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py index e216011..6ae8850 100644 --- a/app/converter/clients/__init__.py +++ b/app/converter/clients/__init__.py @@ -15,7 +15,7 @@ BACKEND_AUDIOCPP = "audiocpp" BACKENDS = (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER) from .base import BaseTTSClient, ConversionCancelled, VOICE_MODE_CLONE, \ - VOICE_MODE_CUSTOM, VOICE_MODES, resolve_request_seed + VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN, VOICE_MODES, resolve_request_seed from .languages import LANGUAGE_ISO_CODES, TTS_LANGUAGES, \ TTS_LANGUAGE_ALIASES, normalize_language from .speakers import QWEN3_TTS_SPEAKERS, SPEAKER_DISPLAY_NAMES, \ @@ -44,7 +44,7 @@ from .audiocpp import ( __all__ = [ # vocabulary "BACKEND_QWEN", "BACKEND_FASTER", "BACKEND_AUDIOCPP", "BACKENDS", - "VOICE_MODE_CUSTOM", "VOICE_MODE_CLONE", "VOICE_MODES", + "VOICE_MODE_CUSTOM", "VOICE_MODE_CLONE", "VOICE_MODE_DESIGN", "VOICE_MODES", # clients "BaseTTSClient", "ConversionCancelled", "resolve_request_seed", "QwenTTSClient", "FasterTTSClient", "AudioCppTTSClient", diff --git a/app/converter/clients/base.py b/app/converter/clients/base.py index a0f28cf..d0d57fb 100644 --- a/app/converter/clients/base.py +++ b/app/converter/clients/base.py @@ -17,12 +17,14 @@ class ConversionCancelled(Exception): """Raised when the run's cancel event is set (between requests).""" -# How a run supplies its voice: a built-in CustomVoice speaker, or by -# cloning a reference audio clip (the faster and audiocpp backends always -# clone server-side; only the Qwen client branches on this at request time). +# How a run supplies its voice: a built-in CustomVoice speaker, by cloning +# a reference audio clip (the faster and audiocpp backends always clone +# server-side; only the Qwen client branches on this at request time), or +# designed from an instruction (Qwen's VoiceDesign model). VOICE_MODE_CUSTOM = "custom_voice" VOICE_MODE_CLONE = "voice_clone" -VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE) +VOICE_MODE_DESIGN = "voice_design" +VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE, VOICE_MODE_DESIGN) def resolve_request_seed() -> int: diff --git a/app/converter/clients/qwen.py b/app/converter/clients/qwen.py index 354ee04..ed3149b 100644 --- a/app/converter/clients/qwen.py +++ b/app/converter/clients/qwen.py @@ -1,4 +1,4 @@ -"""Client for the qwen-tts Gradio demo servers (CustomVoice + Base).""" +"""Client for the qwen-tts Gradio demo servers (CustomVoice / Base / VoiceDesign).""" import io import logging @@ -12,14 +12,15 @@ from .. import config from ..audio import concat_audio_files from ..chunking import split_into_chunks from .base import (BaseTTSClient, ConversionCancelled, resolve_request_seed, - VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, VOICE_MODES) + VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN, + VOICE_MODES) from .languages import normalize_language from .speakers import speaker_display_name logger = logging.getLogger(__name__) -# Fixed model facts: both demos run the 1.7B model (the CustomVoice demo -# takes its full HuggingFace id), and the 12Hz codec outputs 24 kHz audio. +# Fixed model facts: the demos run the 1.7B model (each takes its full +# HuggingFace id), and the 12Hz codec outputs 24 kHz audio. MODEL_SIZE = "1.7B" CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" @@ -31,7 +32,7 @@ class QwenTTSClient(BaseTTSClient): voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, language: Optional[str] = None, api_url: Optional[str] = None, - quiet: bool = False): + instructions: Optional[str] = None, quiet: bool = False): super().__init__(chunks_dir, quiet=quiet) if voice_mode not in VOICE_MODES: raise ValueError( @@ -41,6 +42,11 @@ class QwenTTSClient(BaseTTSClient): self.voice_clone_ref_audio = voice_clone_ref_audio self.voice_clone_ref_text = (voice_clone_ref_text or "").strip() self.skip_transcription = skip_transcription + # Voice design / style instruction (VoiceDesign mode): describes the + # voice to design. Defaults to the configured CustomVoice INSTRUCT so + # a run never sends an empty design prompt. + self.instructions = (instructions if instructions is not None + else config.INSTRUCT).strip() # api_url overrides the configured endpoint for the active voice mode # (used by the hub's "[remote]" backend entries and --api-url). self.api_url = (api_url or "").strip() or None @@ -66,25 +72,28 @@ class QwenTTSClient(BaseTTSClient): # ------------------------------------------------------------------ def _connect(self) -> None: - api_url = self.api_url or ( - config.CLONE_API_URL if self.voice_mode == VOICE_MODE_CLONE - else config.QWEN_API_URL) + # One demo server runs at a time on the configured port, hosting + # whichever model this run selected (CustomVoice / Base / VoiceDesign). + api_url = self.api_url or config.QWEN_API_URL try: if self.voice_mode == VOICE_MODE_CLONE: - # Voice clone uses the Base-model demo, which is a separate server - # from the CustomVoice demo (that one only exposes /run_instruct). + # Voice clone talks to the Base-model demo. self._init_client(api_url, clone=True) self._report(f"[OK] Connected to Voice Clone API at {api_url}") self._resolve_reference_text() else: self._init_client(api_url, clone=False) - self._report("[OK] Connected to Qwen API") + if self.voice_mode == VOICE_MODE_DESIGN: + self._report(f"[OK] Connected to Voice Design API at {api_url}") + else: + self._report("[OK] Connected to Qwen API") except Exception as exc: raise RuntimeError( f"Qwen API initialization failed at {api_url}: {exc}. " "Make sure the Qwen demo server is running and reachable, and that your " "installed Qwen3-TTS version matches this converter's API expectations " - "(voice clone requires the Base-model demo: Qwen/Qwen3-TTS-12Hz-1.7B-Base)." + "(voice clone requires the Base-model demo: Qwen/Qwen3-TTS-12Hz-1.7B-Base; " + "voice design requires: Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign)." ) from exc def _resolve_reference_text(self) -> None: @@ -226,6 +235,8 @@ class QwenTTSClient(BaseTTSClient): result = self._generate_custom_voice(text) elif self.voice_mode == VOICE_MODE_CLONE: result = self._generate_voice_clone(text) + elif self.voice_mode == VOICE_MODE_DESIGN: + result = self._generate_voice_design(text) else: raise ValueError(f"Unknown voice mode: {self.voice_mode}") @@ -276,6 +287,25 @@ class QwenTTSClient(BaseTTSClient): return self.client.predict(**payload, api_name=custom_api) + def _generate_voice_design(self, text: str) -> Tuple: + """Generate audio using VoiceDesign mode (described-voice model).""" + design_api = self._resolve_api_name("/run_voice_design") + # The demo's field is named "design"; older builds may call it + # "instruct" instead. + design_field = ("instruct" + if self._endpoint_accepts_param(design_api, "instruct") + and not self._endpoint_accepts_param(design_api, "design") + else "design") + payload = dict( + text=text, + lang_disp=self.language, + **{design_field: self.instructions}, + ) + if self._endpoint_accepts_param(design_api, "seed"): + payload["seed"] = self._seed + + return self.client.predict(**payload, api_name=design_api) + def _ref_audio_payload(self) -> Dict[str, Any]: """Gradio file payload for the reference audio (built once, reused).""" if self._ref_audio_filedata is None: diff --git a/app/converter/config.py b/app/converter/config.py index ae7189f..4694e80 100644 --- a/app/converter/config.py +++ b/app/converter/config.py @@ -25,21 +25,31 @@ BACKEND = "audiocpp" # BACKEND 1: qwen-tts-demo (qwen) options # ############################################################################### -# There are different API URLs for CustomVoice and Base models so you can run both at once -QWEN_API_URL = "http://127.0.0.1:7860" # CustomVoice model -CLONE_API_URL = "http://127.0.0.1:7861" # Base model - -# Remote (externally-run) server URLs. The hub probes these and offers a -# "[remote]" backend entry when one answers with the expected backend, so an -# externally-started server can be used alongside a locally-managed one. -# Leave empty to disable remote probing for that backend. The defaults match -# the local ports so an external server squatting the local port is found -# without any configuration. -QWEN_REMOTE_URL = "http://127.0.0.1:7860" # CustomVoice model -CLONE_REMOTE_URL = "http://127.0.0.1:7861" # Base model +# The qwen backend runs ONE demo server at a time, on this port. Which model +# the server hosts is chosen per run on the Generate audiobooks screen and +# persisted below (see QWEN_MODEL); switching models restarts the server. +QWEN_API_URL = "http://127.0.0.1:7860" # single qwen-tts-demo server + +# Remote (externally-run) server URL. The hub probes it and offers a +# "[remote]" backend entry when it answers with a known qwen-tts demo (any +# of the three models), so an externally-started server can be used alongside +# a locally-managed one. Leave empty to disable remote probing. The default +# matches the local port so an external server squatting the local port is +# found without any configuration. +QWEN_REMOTE_URL = "http://127.0.0.1:7860" + +# Which model the managed demo server runs (one server hosts one model): +# CustomVoice - built-in speakers (see SPEAKER) +# Base - voice cloning from a reference .wav +# VoiceDesign - voice described by an instruction +# Chosen per run in the Generate-audiobooks form; edited here only as the +# default for the next run. +QWEN_MODEL = "CustomVoice" # Custom voice options SPEAKER = "Vivian" #Vivian, Serena, Uncle_Fu, Dylan, Eric, Ryan, Aiden, Ono_Anna, Sohee +# Style/delivery instruction for CustomVoice runs; also the default design +# instruction when a VoiceDesign run does not override it. INSTRUCT = "Speak naturally and clearly, as if reading a dramatic book to an adult audience." # Don't clone with transcription, only use x-vector-only cloning. Generally "worse" diff --git a/app/converter/converter.py b/app/converter/converter.py index 48d4987..b356448 100644 --- a/app/converter/converter.py +++ b/app/converter/converter.py @@ -24,6 +24,7 @@ from .clients import ( MODEL_SIZE, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, + VOICE_MODE_DESIGN, VOICE_MODES, AudioCppTTSClient, FasterTTSClient, @@ -100,19 +101,23 @@ def setup_directories() -> None: def voice_mode_for(backend: str, voice: Optional[str] = None, - clone: Optional[str] = None) -> str: + clone: Optional[str] = None, + instructions: Optional[str] = None) -> str: """The voice mode a run with these options would use. Mirrors the choice ``audiobook.convert`` makes from the same inputs (faster always clones; audiocpp clones through a server-side voice; - qwen clones only with a reference .wav), so the hub can run the - pre-flight overwrite checks against exactly the output names the - conversion will produce. + qwen designs with instructions, clones only with a reference .wav, and + uses a built-in speaker otherwise), so the hub can run the pre-flight + overwrite checks against exactly the output names the conversion will + produce. """ if backend == BACKEND_FASTER: return VOICE_MODE_CLONE if backend == BACKEND_AUDIOCPP: return VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM + if (instructions or "").strip(): + return VOICE_MODE_DESIGN return VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM @@ -245,6 +250,9 @@ class AudiobookConverter: request_options=self.request_options, api_url=api_url, quiet=quiet) else: + # Qwen: the voice mode picks the request shape (built-in + # speaker, clone from a reference .wav, or a designed voice); + # instructions describe the voice in design mode. self.tts = QwenTTSClient( chunks_dir=CHUNKS_FOLDER, voice_mode=voice_mode, @@ -252,6 +260,7 @@ class AudiobookConverter: voice_clone_ref_text=voice_clone_ref_text, skip_transcription=skip_transcription, language=self.language, + instructions=self.instructions, api_url=api_url, quiet=quiet, ) @@ -281,6 +290,12 @@ class AudiobookConverter: f"Unknown voice mode: {self.voice_mode!r} " f"(expected one of {VOICE_MODES})" ) + if self.backend == BACKEND_QWEN and self.voice_mode == VOICE_MODE_DESIGN \ + and not (self.instructions or "").strip(): + raise ValueError( + "Voice Design mode requires a voice description. " + "Use --instructions \"...\" to describe the voice to synthesize with." + ) if self.voice_mode == VOICE_MODE_CLONE and self.backend == BACKEND_QWEN: if not self.voice_clone_ref_audio: raise ValueError( @@ -336,6 +351,10 @@ class AudiobookConverter: narrator = "designed" else: narrator = speaker_display_name() + elif voice_mode == VOICE_MODE_DESIGN: + # Qwen's VoiceDesign model: the voice is described by an + # instruction and has no speaker name. + narrator = "designed" elif voice_mode == VOICE_MODE_CLONE: narrator = Path(voice_clone_ref_audio).stem else: @@ -722,9 +741,7 @@ class AudiobookConverter: else: tts_client = getattr(self, "tts", None) api_url = (getattr(tts_client, "api_url", None) - or (config.CLONE_API_URL - if self.voice_mode == VOICE_MODE_CLONE - else config.QWEN_API_URL)) + or config.QWEN_API_URL) self._say(f"Qwen API endpoint: {api_url}") self._say(f"Voice mode: {self.voice_mode}") self._say(f"Model size: {MODEL_SIZE} (always)") @@ -734,6 +751,10 @@ class AudiobookConverter: elif self.voice_mode == VOICE_MODE_CLONE: self._say(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") self._say(f"Language: {self.language}") + elif self.voice_mode == VOICE_MODE_DESIGN: + self._say("Backend: qwen-tts (voice from --instructions description)") + self._say(f"Instruction: {self.instructions}") + self._say(f"Language: {self.language}") self._say(f"Output format: {self.output_format}") if self.single_file and self.output_format != "m4b": self._say("Chapter mode: single file (--single-file)") diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md index d0e7c92..5cfb0b3 100644 --- a/app/docs/backend-qwen.md +++ b/app/docs/backend-qwen.md @@ -1,8 +1,10 @@ # Backend Option 2: Qwen3-TTS -The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`) — that's all there is to it, the install asks no questions. The two demo ports live in `app/converter/config.py` (edit them in the hub's **Settings** screen), and you pick the built-in speaker per run on the **Generate audiobooks** screen (it defaults to `SPEAKER` in `app/converter/config.py`). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. +The easiest way is to run `python audiobook.py` → **Configure backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its managed venv (`app/envs/tts`) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate audiobooks** screen (the choice is remembered in `QWEN_MODEL` and re-used by the next autostart; switching models while a managed server is up restarts it with the newly-selected model). You can also start the server from the hub's **Start/Stop Backend Servers** menu, or let a conversion start it automatically. -If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port, so a manually-installed backend works once its server is up. To use demo servers on another machine, set `QWEN_REMOTE_URL`/`CLONE_REMOTE_URL` in `app/converter/config.py` to their `host:port` (defaults `127.0.0.1:7860`/`:7861`) — the hub probes each and offers the matching `qwen-tts [remote]` mode — or pass `--api-url` on the CLI. +If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI. + +Model weights download automatically from HuggingFace into the standard cache (`~/.cache/huggingface/hub`) the first time a server for each model starts — there is nothing else to install per model. Install qwen-tts with pip into your environment: @@ -12,12 +14,27 @@ source audiobook/bin/activate pip install -U qwen-tts ``` -Run the backend with `qwen-tts-demo`. Add `--no-flash-attn` if FlashAttention isn't installed (see below). Note that the Base model and CustomVoice model run on different ports. +Run the backend with `qwen-tts-demo `. Add `--no-flash-attn` if FlashAttention isn't installed (see below). + +## Voice design + +```bash +qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign --ip 127.0.0.1 --port 7860 [--no-flash-attn] +``` + +Then: + +```bash +python audiobook.py --backend qwen \ + --instructions "A warm adult female narrator with a British accent" +``` + +The narrator is tagged "designed" in output file names. ## Voice clone ```bash -qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7861 [--no-flash-attn] +qwen-tts-demo Qwen/Qwen3-TTS-12Hz-1.7B-Base --ip 127.0.0.1 --port 7860 [--no-flash-attn] ``` Then in another terminal: diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py index 99742f3..5cf5633 100644 --- a/app/tests/test_backends.py +++ b/app/tests/test_backends.py @@ -119,44 +119,54 @@ class DetectAllTests(unittest.TestCase): self.assertFalse(status.installed) self.assertFalse(status.configured) - def test_qwen_running_when_either_remote_url_is_up(self): - # Either the CustomVoice or the Base remote URL answering counts as - # running, and the status names which model answered. Probes: Base - # (CLONE_REMOTE_URL) first, then CustomVoice (QWEN_REMOTE_URL). + def test_qwen_running_when_remote_url_is_up(self): + # The single remote URL answering as any of the three demos counts + # as running, and the status names which model answered. from backends import qwen - with patch.object(qwen, "_is_installed", return_value=False), \ - patch.object(qwen.probe, "identify_server", - side_effect=[None, "qwen-custom"]): - status = qwen.detect() - self.assertTrue(status.running) - self.assertTrue(status.remote) - self.assertEqual(status.remote_models, ["CustomVoice"]) - self.assertEqual(status.running_models, ["CustomVoice"]) - with patch.object(qwen, "_is_installed", return_value=False), \ - patch.object(qwen.probe, "identify_server", - side_effect=["qwen-clone", None]): - status = qwen.detect() - self.assertTrue(status.running) - self.assertEqual(status.remote_models, ["Base"]) - self.assertEqual(status.running_models, ["Base"]) - - def test_qwen_running_models_names_both_ports(self): - # Both remote URLs up → both models, Base first (the hub renders - # "running (Base, CustomVoice)"). + for identity, model in (("qwen-custom", "CustomVoice"), + ("qwen-clone", "Base"), + ("qwen-design", "VoiceDesign")): + with self.subTest(identity=identity): + with patch.object(qwen, "_is_installed", return_value=False), \ + patch.object(qwen.probe, "identify_server", + return_value=identity): + status = qwen.detect() + self.assertTrue(status.running) + self.assertTrue(status.remote) + self.assertEqual(status.remote_models, [model]) + self.assertEqual(status.running_models, [model]) + + def test_qwen_detect_uses_one_spec_for_the_configured_model(self): + # One demo server hosts one model on the single port: the spec's + # argv launches config.QWEN_MODEL's repo, and its identity matches. from backends import qwen - with patch.object(qwen, "_is_installed", return_value=False), \ - patch.object(qwen.probe, "identify_server", - side_effect=["qwen-clone", "qwen-custom"]): - status = qwen.detect() - self.assertTrue(status.running) - self.assertEqual(status.remote_models, ["Base", "CustomVoice"]) - self.assertEqual(status.running_models, ["Base", "CustomVoice"]) + from backends.probe import (IDENTITY_QWEN_CLONE, + IDENTITY_QWEN_CUSTOM, + IDENTITY_QWEN_DESIGN) + cases = {"CustomVoice": IDENTITY_QWEN_CUSTOM, + "Base": IDENTITY_QWEN_CLONE, + "VoiceDesign": IDENTITY_QWEN_DESIGN} + for model, identity in cases.items(): + with self.subTest(model=model): + with patch.object(qwen.config, "QWEN_MODEL", model), \ + patch.object(qwen, "_is_installed", + return_value=True), \ + patch("backends.common.server_running", + return_value=False): + status = qwen.detect() + self.assertEqual([spec.name for spec in status.servers], + ["qwen"]) + spec = status.servers[0] + self.assertEqual(spec.identity, identity) + self.assertIn(qwen.MODEL_REPOS[model], spec.argv) + self.assertIn(qwen.MODEL_REPOS[model], + status.launch_hint) def test_qwen_detect_marks_our_server_as_managed(self): from backends import qwen from backends import servers as servers_mod with tempfile.TemporaryDirectory() as td: - (Path(td) / "qwen-custom-server.pid").write_text( + (Path(td) / "qwen-server.pid").write_text( "4242", encoding="utf-8") with patch.object(qwen, "_is_installed", return_value=False), \ patch("backends.common.server_running", @@ -399,11 +409,11 @@ class QwenSetupScreenTests(unittest.TestCase): class QwenUninstallTests(unittest.TestCase): - """qwen.uninstall: stop both servers, then pip-uninstall the package.""" + """qwen.uninstall: stop the single server, then pip-uninstall the package.""" def test_stops_servers_and_pips(self): from backends import qwen - # Pid files exist for both managed servers, so stop runs. + # A pid file exists for the managed server, so stop runs. with patch.object(qwen.servers, "pid_for", return_value=1234), \ patch.object(qwen.servers, "stop") as mk_stop, \ patch.object(qwen.common, "pip_uninstall", @@ -411,7 +421,7 @@ class QwenUninstallTests(unittest.TestCase): rc = qwen.uninstall(emit="EMIT") self.assertEqual(rc, 0) self.assertEqual([c.args[0] for c in mk_stop.call_args_list], - ["qwen-custom", "qwen-clone"]) + ["qwen"]) # The task view's emit is forwarded so pip never touches the terminal. mk_pip.assert_called_once_with([qwen.QWEN_PIP_PKG], emit="EMIT") @@ -437,7 +447,7 @@ class QwenUninstallTests(unittest.TestCase): patch.object(qwen.common, "pip_uninstall") as mk_pip: rc = qwen.uninstall(cancel=cancel) self.assertEqual(rc, 130) - self.assertEqual(mk_stop.call_count, 2) + self.assertEqual(mk_stop.call_count, 1) mk_pip.assert_not_called() def test_pip_failure_propagates_the_exit_code(self): diff --git a/app/tests/test_backends_probe.py b/app/tests/test_backends_probe.py index 08f9fd9..742e208 100644 --- a/app/tests/test_backends_probe.py +++ b/app/tests/test_backends_probe.py @@ -63,6 +63,13 @@ class IdentifyServerTests(unittest.TestCase): self.assertEqual(probe.identify_server("http://x:7861"), "qwen-clone") + def test_qwen_design_identified(self): + with patch.object(probe.common, "server_running", return_value=True), \ + self._patch_http({"/info": {"named_endpoints": + {"/run_voice_design": {}}}}): + self.assertEqual(probe.identify_server("http://x:7860"), + "qwen-design") + def test_unreachable_returns_none(self): with patch.object(probe.common, "server_running", return_value=False): self.assertIsNone(probe.identify_server("http://127.0.0.1:8080")) diff --git a/app/tests/test_converter_progress.py b/app/tests/test_converter_progress.py index 2c9c330..9a1147c 100644 --- a/app/tests/test_converter_progress.py +++ b/app/tests/test_converter_progress.py @@ -20,6 +20,7 @@ from converter.clients import ( BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, + VOICE_MODE_DESIGN, ) from converter import converter as converter_mod from converter.converter import ( @@ -51,6 +52,18 @@ class VoiceModeForTests(unittest.TestCase): self.assertEqual(voice_mode_for(BACKEND_QWEN), VOICE_MODE_CUSTOM) + def test_qwen_instructions_design(self): + # Qwen: instructions alone select the VoiceDesign model, taking + # precedence over a clone reference. + self.assertEqual(voice_mode_for(BACKEND_QWEN, + instructions="A warm narrator"), + VOICE_MODE_DESIGN) + self.assertEqual(voice_mode_for(BACKEND_QWEN, clone="x.wav", + instructions="A warm narrator"), + VOICE_MODE_DESIGN) + self.assertEqual(voice_mode_for(BACKEND_QWEN, instructions=" "), + VOICE_MODE_CUSTOM) + class PromptOverwriteConfirmTests(unittest.TestCase): def test_confirm_callback_receives_message_and_default(self): diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index 48cef0b..44c63fd 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -1376,13 +1376,15 @@ class ConvertFlowTests(unittest.TestCase): self.assertTrue(single["visible"](fields)) # ------------------------------------------------------------------ - # qwen: speaker or clone + # qwen: model picker (Base / CustomVoice / VoiceDesign) # ------------------------------------------------------------------ def test_qwen_builds_speaker_and_clone_form(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian", "Serena"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): self._answer_form(backend="qwen", mode="custom", speaker="Serena", clone="") # The fake mirrors the real update_config_value contract: @@ -1399,29 +1401,56 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[0], "convert") self.assertEqual(cmd[1], hub.BACKEND_QWEN) self.assertIsNone(cmd[2]["clone"]) + self.assertIsNone(cmd[2].get("instructions")) self.assertEqual(speaker_in_memory, "Serena") fields = self.tui.forms_seen[0][1] self.assertEqual([f["key"] for f in fields], ["backend", "mode", "speaker", "clone", + "qwen_instructions", "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) mode_field = self._field("mode") self.assertEqual(mode_field["choices"], - [("Built-in speaker", "custom"), - ("Clone from a .wav file", "clone")]) + [("CustomVoice (built-in voices)", "custom"), + ("Base (voice cloning)", "clone"), + ("VoiceDesign (design)", "design")]) speaker_field = self._field("speaker") clone_field = self._field("clone") - # Speaker shows in custom mode; the .wav path shows in clone mode. + design_field = self._field("qwen_instructions") + # Speaker shows in custom mode; the .wav path in clone mode and the + # instruction in design mode. self.assertTrue(speaker_field["visible"](fields)) self.assertFalse(clone_field["visible"](fields)) + self.assertFalse(design_field["visible"](fields)) mode_field["value"] = "clone" self.assertFalse(speaker_field["visible"](fields)) self.assertTrue(clone_field["visible"](fields)) + mode_field["value"] = "design" + self.assertFalse(speaker_field["visible"](fields)) + self.assertFalse(clone_field["visible"](fields)) + self.assertTrue(design_field["visible"](fields)) - def test_qwen_clone_mode_passes_path_and_keeps_speaker(self): + def test_qwen_design_mode_passes_instructions_and_persists_model(self): with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ - patch.object(hub.config, "SPEAKER", "Vivian"): + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): + self._answer_form(backend="qwen", mode="design", + qwen_instructions="A warm narrator") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + self.assertEqual(cmd[2]["clone"], None) + self.assertEqual(cmd[2]["instructions"], "A warm narrator") + # The model switch is persisted (CustomVoice -> VoiceDesign). + mk_update.assert_called_once_with("QWEN_MODEL", "VoiceDesign") + + def test_qwen_clone_mode_passes_path_and_persists_model(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): self._answer_form(backend="qwen", mode="clone", speaker="Vivian", clone="/tmp/ref.wav") with patch.object(hub.common, "update_config_value") as mk_update: @@ -1429,6 +1458,22 @@ class ConvertFlowTests(unittest.TestCase): [self._ready("qwen", "qwen-tts")]) self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") # Clone mode does not touch the global speaker. + keys = [c.args[0] for c in mk_update.call_args_list] + self.assertNotIn("SPEAKER", keys) + # ...but remembers the switch to the Base model. + self.assertEqual(keys, ["QWEN_MODEL"]) + self.assertEqual(mk_update.call_args.args[1], "Base") + + def test_qwen_same_model_run_persists_nothing_new(self): + with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ + patch.object(hub.config, "SPEAKER", "Vivian"), \ + patch.object(hub.qwen_backend.config, "QWEN_MODEL", + "CustomVoice"): + self._answer_form(backend="qwen", mode="custom", speaker="Vivian") + with patch.object(hub.common, "update_config_value") as mk_update: + cmd = self._convert(None, + [self._ready("qwen", "qwen-tts")]) + self.assertIsNotNone(cmd) mk_update.assert_not_called() # ------------------------------------------------------------------ @@ -1513,10 +1558,10 @@ class ConvertFlowTests(unittest.TestCase): def test_qwen_remote_limited_modes_and_api_url(self): # A remote qwen with only the Base (clone) demo answering: the form - # offers only clone mode and targets the clone remote URL. + # offers only the Base model and targets the single remote URL. st = self._remote( "qwen", "qwen-tts", - remote_urls={"qwen-clone": "http://10.0.0.5:7861"}, + remote_urls={"qwen": "http://10.0.0.5:7861"}, remote_models=["Base"]) with patch.object(hub.qwen_backend, "QWEN_SPEAKERS", ["Vivian"]), \ patch.object(hub.config, "SPEAKER", "Vivian"): @@ -1527,7 +1572,7 @@ class ConvertFlowTests(unittest.TestCase): self.assertEqual(cmd[2]["clone"], "/tmp/ref.wav") self.assertEqual(cmd[2]["api_url"], "http://10.0.0.5:7861") self.assertEqual(self._field("mode")["choices"], - [("Clone from a .wav file", "clone")]) + [("Base (voice cloning)", "clone")]) # ------------------------------------------------------------------ # multiple backends: the Backend picker gates which options show @@ -1560,6 +1605,7 @@ class ConvertFlowTests(unittest.TestCase): [f["key"] for f in fields], ["backend", "model_id", "audiocpp_voice", "instructions", "request_options", "mode", "speaker", "clone", + "qwen_instructions", "output_format", "language", "speed", "single_file", "debug", "stop_and_exit"]) # The form opens on the configured default (audio.cpp): its fields @@ -1572,7 +1618,7 @@ class ConvertFlowTests(unittest.TestCase): self.assertFalse(self._field("request_options")["visible"](fields)) # Language shows for every backend except faster entries. self.assertTrue(self._field("language")["visible"](fields)) - for key in ("mode", "speaker", "clone"): + for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) # Picking qwen in the Backend field swaps which options show. fields[0]["value"] = "qwen" @@ -1591,26 +1637,23 @@ class ConvertFlowTests(unittest.TestCase): fields[0]["value"] = "audiocpp" for key in ("model_id", "audiocpp_voice"): self.assertTrue(self._field(key)["visible"](fields)) - for key in ("mode", "speaker", "clone"): + for key in ("mode", "speaker", "clone", "qwen_instructions"): self.assertFalse(self._field(key)["visible"](fields)) class SelectSpecTests(unittest.TestCase): - """_select_spec: mode-aware server selection (qwen has two servers).""" + """_select_spec: single-server selection (qwen hosts one model at a time).""" def _qwen_status(self): return BackendStatus( "qwen", "qwen-tts", installed=True, configured=True, - servers=[ServerSpec("qwen-custom", "http://127.0.0.1:7860", []), - ServerSpec("qwen-clone", "http://127.0.0.1:7861", [])]) + servers=[ServerSpec("qwen", "http://127.0.0.1:7860", [])]) - def test_qwen_custom_mode(self): + def test_qwen_returns_the_single_spec(self): spec = hub._select_spec(self._qwen_status(), {"clone": None}) - self.assertEqual(spec.name, "qwen-custom") - - def test_qwen_clone_mode(self): + self.assertEqual(spec.name, "qwen") spec = hub._select_spec(self._qwen_status(), {"clone": "ref.wav"}) - self.assertEqual(spec.name, "qwen-clone") + self.assertEqual(spec.name, "qwen") def test_audiocpp_returns_single_spec(self): st = BackendStatus("audiocpp", "audio.cpp", installed=True, @@ -1628,7 +1671,7 @@ class SelectSpecTests(unittest.TestCase): class PrepareRunConfigTests(unittest.TestCase): """_prepare_run_config: the run view's inputs from the accepted form.""" - def _spec(self, name="qwen-custom", url="http://127.0.0.1:7860"): + def _spec(self, name="qwen", url="http://127.0.0.1:7860"): return ServerSpec(name, url, ["x"]) def test_remote_targets_the_api_url(self): @@ -1644,14 +1687,32 @@ class PrepareRunConfigTests(unittest.TestCase): def test_autostart_sets_the_spec_and_pops_the_flag(self): spec = self._spec() - kwargs = {"autostart": "qwen-custom"} + kwargs = {"autostart": "qwen"} with patch.object(hub, "detect_all", return_value=[]), \ patch.object(hub, "_find_spec", return_value=spec): cfg = hub._prepare_run_config("qwen", kwargs) self.assertIs(cfg.autostart_spec, spec) - self.assertEqual(cfg.server_name, "qwen-custom") + self.assertFalse(cfg.restart_first) + self.assertEqual(cfg.server_name, "qwen") self.assertNotIn("autostart", kwargs) + def test_restart_first_stops_and_boots_before_converting(self): + # A running managed server hosting another model than this run + # selected: the recorded spec boots again after a stop. + spec = self._spec() + status = BackendStatus("qwen", "qwen-tts", installed=True, + configured=True, servers=[spec]) + kwargs = {"restart_server": "qwen"} + with patch.object(hub, "detect_all", return_value=[status]), \ + patch("backends.common.server_running", + return_value=True), \ + patch.object(hub.servers, "alive", return_value=True): + cfg = hub._prepare_run_config("qwen", kwargs) + self.assertIs(cfg.autostart_spec, spec) + self.assertTrue(cfg.restart_first) + self.assertNotIn("restart_server", kwargs) + self.assertEqual(cfg.server_url, spec.url) + def test_stop_and_exit_travels_on_the_config_not_the_kwargs(self): # The run-view toggle is not a converter kwarg: it moves onto the # config (and defaults to off when the form did not send it). @@ -1846,7 +1907,7 @@ class AddAutostartTests(unittest.TestCase): """_add_autostart: always starts the server when it isn't running.""" def _status(self): - spec = ServerSpec("qwen-custom", "http://127.0.0.1:7860", ["x"]) + spec = ServerSpec("qwen", "http://127.0.0.1:7860", ["x"]) return BackendStatus("qwen", "qwen-tts", installed=True, configured=True, running=False, servers=[spec]) @@ -1855,15 +1916,43 @@ class AddAutostartTests(unittest.TestCase): cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ patch("backends.common.server_running", return_value=False): - hub._add_autostart(cmd, [self._status()]) - self.assertEqual(cmd[2]["autostart"], "qwen-custom") + self.assertIsNone(hub._add_autostart(cmd, [self._status()])) + self.assertEqual(cmd[2]["autostart"], "qwen") - def test_no_autostart_when_server_already_running(self): + def test_no_autostart_when_server_already_running_same_model(self): cmd = ("convert", "qwen", {"clone": None}) with patch.object(hub, "detect_all", return_value=[self._status()]), \ - patch("backends.common.server_running", return_value=True): - hub._add_autostart(cmd, [self._status()]) + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.backend_probe, "identify_server", + return_value="qwen-custom"): + self.assertIsNone(hub._add_autostart(cmd, [self._status()])) + self.assertNotIn("autostart", cmd[2]) + + def test_running_server_hosting_another_model_is_restarted(self): + # The managed qwen server hosts CustomVoice but the run selected + # the Base (clone) model: restart (stop + boot) before converting. + cmd = ("convert", "qwen", {"clone": "/tmp/ref.wav"}) + with patch.object(hub, "detect_all", return_value=[self._status()]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=True), \ + patch.object(hub.backend_probe, "identify_server", + return_value="qwen-custom"): + self.assertIsNone(hub._add_autostart(cmd, [self._status()])) + self.assertEqual(cmd[2]["restart_server"], "qwen") + + def test_foreign_server_with_wrong_model_refuses_the_run(self): + cmd = ("convert", "qwen", {"clone": "/tmp/ref.wav"}) + with patch.object(hub, "detect_all", return_value=[self._status()]), \ + patch("backends.common.server_running", return_value=True), \ + patch.object(hub.servers, "alive", return_value=False), \ + patch.object(hub.backend_probe, "identify_server", + return_value="qwen-custom"): + message = hub._add_autostart(cmd, [self._status()]) + self.assertIsNotNone(message) + self.assertIn("CustomVoice", message) + self.assertIn("Base", message) self.assertNotIn("autostart", cmd[2]) + self.assertNotIn("restart_server", cmd[2]) def test_no_autostart_for_remote_conversion(self): # A remote conversion (api_url set) never autostarts: the server is @@ -1881,9 +1970,9 @@ class SettingsTests(unittest.TestCase): _SETTING_KEYS = ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", - "QWEN_API_URL", "CLONE_API_URL", + "QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL", - "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "QWEN_REMOTE_URL", "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL") def _snapshot_settings(self): @@ -1946,12 +2035,11 @@ class SettingsTests(unittest.TestCase): "language": "en", "chunk_size": "300", "stop_and_exit": False, "unload_models": True, - "qwen_custom_port": "7862", "qwen_clone_port": "7863", + "qwen_port": "7862", "faster_port": "8001", "audiocpp_port": "8081", "audiocpp_remote_url": "10.0.0.5:8080", "faster_remote_url": "http://10.0.0.6:8000", - "qwen_custom_remote_url": "", - "qwen_clone_remote_url": ""} + "qwen_remote_url": ""} with patch.object(hub.common, "update_config_value", fake_update), \ patch.object(hub, "_sync_audiocpp_server_port"): @@ -1965,12 +2053,10 @@ class SettingsTests(unittest.TestCase): "STOP_SERVER_AND_EXIT": False, "AUDIOCPP_UNLOAD_MODELS": True, "QWEN_API_URL": "http://127.0.0.1:7862", - "CLONE_API_URL": "http://127.0.0.1:7863", "FASTER_API_URL": "http://127.0.0.1:8001", "AUDIOCPP_API_URL": "http://127.0.0.1:8081", "QWEN_REMOTE_URL": "", - "CLONE_REMOTE_URL": "", "FASTER_REMOTE_URL": "http://10.0.0.6:8000", "AUDIOCPP_REMOTE_URL": @@ -1993,7 +2079,7 @@ class SettingsTests(unittest.TestCase): "language": "English", "chunk_size": "250", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} with patch.object(hub.common, "update_config_value") as mk_update: with self.assertRaises(ValueError): @@ -2032,7 +2118,7 @@ class SettingsTests(unittest.TestCase): "language": "English", "chunk_size": "300", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} applied = [] @@ -2051,10 +2137,8 @@ class SettingsTests(unittest.TestCase): ["audio_format", "audio_bitrate", "language", "chunk_size", "stop_and_exit", "unload_models", "audiocpp_port", - "faster_port", "qwen_custom_port", - "qwen_clone_port", "audiocpp_remote_url", - "faster_remote_url", "qwen_custom_remote_url", - "qwen_clone_remote_url"]) + "faster_port", "qwen_port", "audiocpp_remote_url", + "faster_remote_url", "qwen_remote_url"]) kinds = {f["key"]: f["kind"] for f in captured["fields"]} self.assertEqual(kinds["audio_format"], "choice") self.assertEqual(kinds["audio_bitrate"], "text") @@ -2063,7 +2147,7 @@ class SettingsTests(unittest.TestCase): self.assertEqual(kinds["unload_models"], "bool") self.assertEqual(kinds["audiocpp_remote_url"], "text") labels = {f["key"]: f["label"] for f in captured["fields"]} - self.assertEqual(labels["qwen_clone_port"], "qwen-tts Base port") + self.assertEqual(labels["qwen_port"], "qwen-tts port") self.assertEqual(labels["audiocpp_remote_url"], "audio.cpp remote URL") self.assertNotIn("(clone)", " ".join(labels.values())) @@ -2073,15 +2157,14 @@ class SettingsTests(unittest.TestCase): self.assertTrue(notes["audiocpp_port"]) self.assertTrue(notes["audiocpp_remote_url"]) self.assertIsNone(notes["audio_format"]) - self.assertIsNone(notes["qwen_custom_port"]) + self.assertIsNone(notes["qwen_port"]) self.assertEqual(applied, [{"audio_format": "ogg", "audio_bitrate": "192k", "language": "English", "chunk_size": "300", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", - "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"}]) # Saving is silent: no confirmation flash either way. @@ -2193,7 +2276,7 @@ class SettingsTests(unittest.TestCase): return {"audio_format": "m4b", "audio_bitrate": "128k", "language": "English", "chunk_size": "250", "stop_and_exit": True, "unload_models": True, - "qwen_custom_port": "7860", "qwen_clone_port": "7861", + "qwen_port": "7860", "faster_port": "8000", "audiocpp_port": "8080"} applied = [] @@ -2225,9 +2308,9 @@ class SettingsTests(unittest.TestCase): ("AUDIO_FORMAT", "AUDIO_BITRATE", "LANGUAGE", "CHUNK_SIZE", "STOP_SERVER_AND_EXIT", "AUDIOCPP_UNLOAD_MODELS", - "QWEN_API_URL", "CLONE_API_URL", + "QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL", - "QWEN_REMOTE_URL", "CLONE_REMOTE_URL", + "QWEN_REMOTE_URL", "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) @@ -2244,11 +2327,9 @@ class SettingsTests(unittest.TestCase): "STOP_SERVER_AND_EXIT = True\n" "AUDIOCPP_UNLOAD_MODELS = True\n" 'QWEN_API_URL = "http://127.0.0.1:7860"\n' - 'CLONE_API_URL = "http://127.0.0.1:7861"\n' 'FASTER_API_URL = "http://127.0.0.1:8000"\n' 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n' 'QWEN_REMOTE_URL = "http://127.0.0.1:7860"\n' - 'CLONE_REMOTE_URL = "http://127.0.0.1:7861"\n' 'FASTER_REMOTE_URL = "http://127.0.0.1:8000"\n' 'AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"\n', encoding="utf-8") @@ -2270,7 +2351,7 @@ class SettingsTests(unittest.TestCase): def test_settings_menu_updates_backend_ports(self): import tempfile original = {name: getattr(hub.config, name) for name in - ("QWEN_API_URL", "CLONE_API_URL", + ("QWEN_API_URL", "FASTER_API_URL", "AUDIOCPP_API_URL")} self.addCleanup(lambda: [setattr(hub.config, name, value) for name, value in original.items()]) @@ -2278,20 +2359,17 @@ class SettingsTests(unittest.TestCase): path = Path(td) / "config.py" path.write_text( 'QWEN_API_URL = "http://127.0.0.1:7860"\n' - 'CLONE_API_URL = "http://127.0.0.1:7861"\n' 'FASTER_API_URL = "http://127.0.0.1:8000"\n' 'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n', encoding="utf-8") self._snapshot_settings() for key, value in ( ("QWEN_API_URL", "http://127.0.0.1:7862"), - ("CLONE_API_URL", "http://127.0.0.1:7863"), ("FASTER_API_URL", "http://127.0.0.1:8001"), ("AUDIOCPP_API_URL", "http://127.0.0.1:8081")): hub.common.update_config_value(key, value, config_path=path) text = path.read_text(encoding="utf-8") self.assertIn('QWEN_API_URL = "http://127.0.0.1:7862"', text) - self.assertIn('CLONE_API_URL = "http://127.0.0.1:7863"', text) self.assertIn('FASTER_API_URL = "http://127.0.0.1:8001"', text) self.assertIn('AUDIOCPP_API_URL = "http://127.0.0.1:8081"', text) diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 31a8ebe..f2d5180 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -363,6 +363,45 @@ class WorkerTests(_FakeTui, unittest.TestCase): self.assertIn("ERROR - boom", text) self.assertIn("error", [e["kind"] for e in self._drain(view)]) + def test_restart_first_stops_then_boots_the_new_model(self): + # The managed qwen server hosts another model than this run picked: + # the worker stops it (releasing the single port) before booting + # the spec again — whose argv now names the newly-selected model. + from types import SimpleNamespace + spec = SimpleNamespace(name="qwen") + events = [] + + def fake_start(spec_, progress=None, cancel=None): + events.append(("start", progress)) + progress({"kind": "ready", "name": "qwen", + "url": "http://127.0.0.1:7860"}) + return True + + with patch("audiobook.convert") as mk_convert, \ + patch.object(runview.servers, "stop") as mk_stop, \ + patch.object(runview.servers, "start", + side_effect=fake_start): + view = self.make_view(autostart_spec=spec, restart_first=True, + server_name="qwen") + view._worker_main() + mk_stop.assert_called_once_with("qwen") + events = self._drain(view) + kinds = [e["kind"] for e in events] + self.assertIn("ready", kinds) + self.assertNotIn("error", kinds) + mk_convert.assert_called_once() + + def test_no_restart_without_the_flag(self): + # A plain autostart never stops a server first. + from types import SimpleNamespace + spec = SimpleNamespace(name="qwen") + with patch("audiobook.convert"), \ + patch.object(runview.servers, "stop") as mk_stop, \ + patch.object(runview.servers, "start", return_value=True): + view = self.make_view(autostart_spec=spec, server_name="qwen") + view._worker_main() + mk_stop.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py index 2b2ac1c..49eb6f6 100644 --- a/app/tests/test_tts.py +++ b/app/tests/test_tts.py @@ -31,6 +31,8 @@ from converter.clients import ( TTS_LANGUAGES, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, + VOICE_MODE_DESIGN, + VOICE_MODES, AudioCppTTSClient, FasterTTSClient, QwenTTSClient, @@ -494,6 +496,80 @@ class QwenTTSClientGenerateTests(unittest.TestCase): mock_generate.assert_not_called() +class QwenTTSClientVoiceDesignTests(unittest.TestCase): + """Qwen VoiceDesign mode: instructions and the /run_voice_design call.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + + def tearDown(self): + self._tmp.cleanup() + + def _client(self, instructions=None): + client = QwenTTSClient.__new__(QwenTTSClient) + client.chunks_dir = Path(self._tmp.name) + client.voice_mode = VOICE_MODE_DESIGN + client.language = config.LANGUAGE + client.instructions = (instructions if instructions is not None + else config.INSTRUCT).strip() + client.api_info = {"named_endpoints": {"/run_voice_design": { + "parameters": [ + {"parameter_name": "text"}, + {"parameter_name": "lang_disp"}, + {"parameter_name": "design"}, + ]}}} + return client + + def _fake_output(self) -> str: + out = Path(self._tmp.name) / "server_out.wav" + out.write_bytes(b"\x01\x00") + return str(out) + + def test_voice_mode_design_is_valid(self): + self.assertIn(VOICE_MODE_DESIGN, VOICE_MODES) + + def test_generate_payload_and_return(self): + client = self._client(instructions="A warm narrator") + fake = MagicMock(return_value=(self._fake_output(),)) + with patch.object(client, "_generate_voice_design", fake): + result = client._generate_sub_request( + "Hello there.", self._tmp.name, 1, 1, 1) + fake.assert_called_once_with("Hello there.") + self.assertEqual(Path(result).name, "part_01.wav") + + def test_payload_uses_design_field_language_and_instruction(self): + client = self._client(instructions="A warm narrator") + captured = {} + + def fake_predict(**payload): + captured.update(payload) + return (self._fake_output(),) + + client.client = MagicMock() + client.client.predict.side_effect = fake_predict + result = client._generate_voice_design("Hi.") + self.assertEqual(captured["text"], "Hi.") + self.assertEqual(captured["lang_disp"], config.LANGUAGE) + self.assertEqual(captured["design"], "A warm narrator") + self.assertNotIn("seed", captured) # not accepted by this endpoint + self.assertEqual(result, (self._fake_output(),)) + + def test_payload_defaults_instructions_to_config(self): + client = self._client(instructions=None) + self.assertEqual(client.instructions, + (config.INSTRUCT or "").strip()) + + def test_unknown_api_falls_back_to_the_requested_name(self): + client = self._client() + client.api_info = {"named_endpoints": {}} + client.client = MagicMock() + client.client.predict.return_value = (self._fake_output(),) + client._generate_voice_design("Hi.") + _, kwargs = client.client.predict.call_args + self.assertEqual(kwargs["api_name"], "/run_voice_design") + + + class AudioCppTTSClientHealthTests(unittest.TestCase): """Connection behavior of the audio.cpp client.""" @@ -1682,6 +1758,28 @@ class BackendWiringTests(unittest.TestCase): AudiobookConverter(voice_mode=VOICE_MODE_CLONE, backend=BACKEND_QWEN) + def test_qwen_design_mode_without_instructions_rejected(self): + # A VoiceDesign run needs a description; an empty instructions + # value (not even the config default) is refused up front. + with patch("converter.converter.QwenTTSClient"): + with self.assertRaises(ValueError): + AudiobookConverter(voice_mode=VOICE_MODE_DESIGN, + backend=BACKEND_QWEN, instructions=" ") + + def test_qwen_design_mode_threads_instructions_to_the_client(self): + with patch("converter.converter.QwenTTSClient") as mock_qwen: + AudiobookConverter(voice_mode=VOICE_MODE_DESIGN, + backend=BACKEND_QWEN, + instructions="A warm adult female narrator") + _, kwargs = mock_qwen.call_args + self.assertEqual(kwargs["instructions"], + "A warm adult female narrator") + + def test_qwen_design_narrator_tag_uses_designed(self): + self.assertEqual(AudiobookConverter.compute_narrator_tag( + BACKEND_QWEN, None, VOICE_MODE_DESIGN, None, + "A warm adult female narrator"), "designed") + def test_api_url_override_reaches_each_client(self): # A remote conversion threads api_url through to the selected client. with patch("converter.converter.AudioCppTTSClient") as mock_audiocpp: @@ -1709,8 +1807,8 @@ class BackendWiringTests(unittest.TestCase): chunks_dir=converter_mod.CHUNKS_FOLDER, voice_mode=VOICE_MODE_CUSTOM, voice_clone_ref_audio=None, voice_clone_ref_text=None, skip_transcription=False, - language=config.LANGUAGE, api_url="http://10.0.0.5:7860", - quiet=False) + language=config.LANGUAGE, instructions=None, + api_url="http://10.0.0.5:7860", quiet=False) def test_audiocpp_clone_mode_does_not_require_reference(self): # Cloning is server-side for the audiocpp backend, so the diff --git a/app/ui/hub.py b/app/ui/hub.py index 0fd1b4d..10484d2 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -362,7 +362,15 @@ class _Hub: cmd = mapper(result) if cmd is None: return tui.Wizard.BACK - _add_autostart(cmd, statuses) + # The mapper may have persisted settings (qwen model/speaker), + # making the form-time statuses stale — the autostart plan + # must read the freshly-configured server argv/model. + invalidate_detect_cache() + statuses = detect_all(refresh=True) + autostart_error = _add_autostart(cmd, statuses) + if autostart_error: + tui.flash(self.stdscr, autostart_error, "err") + continue try: ok = _preflight(self.stdscr, cmd) except _BackToForm: @@ -451,9 +459,8 @@ class _Hub: toggles it directly (starts a stopped server, stops a running one) without an extra action menu. The state lives in the table, not on the entries, because the menu's selection bar would cover inline - colors. A backend with a single server is labelled by its name; a - multi-server backend (qwen: CustomVoice + Base) gets one entry per - server, suffixed with the server name. + colors. Each server is labelled by its backend's name; qwen hosts + one model at a time (config.QWEN_MODEL decides which). """ statuses = detect_all() candidates = [st for st in statuses if st.installed] @@ -735,7 +742,8 @@ def _convert_form(stdscr) -> Optional[tuple]: The first field is the Backend picker; the remaining fields are that backend's options (audio.cpp: model/voice/instructions; qwen: - speaker or clone .wav; faster: voice), plus the shared output + model + speaker / clone .wav / design instruction; faster: voice), + plus the shared output settings. A backend appears once as a managed entry ("audio.cpp") when it is installed+configured here, and once as a remote entry ("audio.cpp [remote]") when a running server was found at its remote @@ -825,7 +833,8 @@ def _preflight(stdscr, cmd: tuple) -> bool: """ _kind, backend, kwargs = cmd voice_mode = voice_mode_for(backend, kwargs.get("voice"), - kwargs.get("clone")) + kwargs.get("clone"), + kwargs.get("instructions")) def confirm(message: str, default: bool) -> bool: answer = tui.confirm(stdscr, message, default=default, @@ -1229,35 +1238,49 @@ def _qwen_fields(remote_modes: Optional[list] = None, prefix: str = "") -> Optional[tuple]: """qwen-specific fields and a result mapper for the Convert form. - Returns ``(fields, mapper)`` where FIELDS are the qwen options - (Voice mode / Speaker / Clone .wav path) and MAPPER turns a - submitted form values dict into the qwen converter kwargs. qwen - always has options to offer, so it never signals unavailability. - PREFIX namespaces the field keys ("" for the managed entry) so two - entries of this backend can share one form without overwriting each - other. - - For the managed entry REMOTE_MODES/URLS are None and the mode picker - offers both modes, targeting the configured local URLs. For a - "[remote]" entry REMOTE_MODES names which demos answered remotely - ("CustomVoice" and/or "Base") and URLS maps "qwen-custom"/"qwen-clone" - to their URLs: the mode picker is limited to the available demos, and - the mapper passes the matching remote URL as ``api_url``. + Returns ``(fields, mapper)`` where FIELDS are the qwen options — which + model the demo server hosts (Base (voice cloning) / CustomVoice (built-in + voices) / VoiceDesign (design)), plus the per-model controls: Speaker on + CustomVoice, Clone .wav path on Base, Instructions on VoiceDesign — and + MAPPER turns a submitted form values dict into the qwen converter + kwargs. qwen always has options to offer, so it never signals + unavailability. PREFIX namespaces the field keys ("" for the managed + entry) so two entries of this backend can share one form without + overwriting each other. + + One demo server hosts one model at a time, so the picked model decides + which server must be up. For the managed entry REMOTE_MODES/URLS are + None: the picker offers every model and the choice (plus any changed + speaker) is persisted to app/converter/config.py so the autostart boots + the same model again later. For a "[remote]" entry REMOTE_MODES names + which demos answered remotely ("Base", "CustomVoice" and/or + "VoiceDesign") and URLS maps "qwen" to its URL: the picker is limited to + the available models and the mapper passes the URL as ``api_url``. """ remote_modes = list(remote_modes or []) urls = dict(urls or {}) - mode_choices = [] - if urls.get("qwen-custom") or not remote_modes: - mode_choices.append(("Built-in speaker", "custom")) - if urls.get("qwen-clone") or not remote_modes: - mode_choices.append(("Clone from a .wav file", "clone")) - default_mode = mode_choices[0][1] if mode_choices else "custom" + mode_keys = (("custom", "CustomVoice"), + ("clone", "Base"), ("design", "VoiceDesign")) + model_choices = [ + ("CustomVoice (built-in voices)", "custom"), + ("Base (voice cloning)", "clone"), + ("VoiceDesign (design)", "design"), + ] + if remote_modes: + available = set(remote_modes) + model_choices = [(label, value) for (label, value) in model_choices + if dict(mode_keys)[value] in available] + by_value = dict(model_choices) + configured_mode = dict(mode_keys).get( + qwen_backend.current_model(), model_choices[0][1]) + default_mode = configured_mode if configured_mode in by_value \ + else model_choices[0][1] speakers = list(qwen_backend.QWEN_SPEAKERS) default_speaker = config.SPEAKER if config.SPEAKER in speakers \ else speakers[0] fields = [ - {"key": prefix + "mode", "label": "Voice mode", "kind": "choice", - "value": default_mode, "choices": mode_choices}, + {"key": prefix + "mode", "label": "Model", "kind": "choice", + "value": default_mode, "choices": model_choices}, {"key": prefix + "speaker", "label": "Speaker", "kind": "choice", "value": default_speaker, "choices": speakers, "visible": lambda fs: _field_value(fs, prefix + "mode") == "custom"}, @@ -1267,22 +1290,34 @@ def _qwen_fields(remote_modes: Optional[list] = None, and s.lower().endswith(".wav")) else "Enter the path to an existing .wav file", "visible": lambda fs: _field_value(fs, prefix + "mode") == "clone"}, + {"key": prefix + "qwen_instructions", "label": "Instructions", + "kind": "text", "value": config.INSTRUCT, + "help": ["Describe the voice to design, e.g.", + '"A warm adult female narrator with a British accent".'], + "validate": lambda s: None if s.strip() else + "Describe the voice to design", + "visible": lambda fs: _field_value(fs, prefix + "mode") == "design"}, ] def mapper(result) -> Optional[tuple]: - clone = result[prefix + "clone"].strip() \ - if result[prefix + "mode"] == "clone" else None - speaker = result[prefix + "speaker"] - if result[prefix + "mode"] == "custom" and speaker != config.SPEAKER: - # Persist the speaker choice for this and future runs (mirrors - # the qwen setup wizard); update_config_value keeps both the - # file and the imported module in sync. - common.update_config_value("SPEAKER", speaker) + mode = result[prefix + "mode"] + clone = result[prefix + "clone"].strip() if mode == "clone" else None kwargs = {"clone": clone, **_common_kwargs(result)} + if mode == "design": + kwargs["instructions"] = result[prefix + "qwen_instructions"] + if not urls: + # Managed entry: persist the choices for this and future runs, + # so the server autostart boots the same model again + # (update_config_value keeps both the file and the imported + # module in sync). + model = dict(mode_keys)[mode] + if model != qwen_backend.current_model(): + common.update_config_value("QWEN_MODEL", model) + speaker = result.get(prefix + "speaker", config.SPEAKER) + if mode == "custom" and speaker != config.SPEAKER: + common.update_config_value("SPEAKER", speaker) if urls: - api_url = urls.get("qwen-clone") \ - if result[prefix + "mode"] == "clone" \ - else urls.get("qwen-custom") + api_url = urls.get("qwen") if api_url: kwargs["api_url"] = api_url return ("convert", BACKEND_QWEN, kwargs) @@ -1403,14 +1438,10 @@ def _settings_fields() -> list: "kind": "text", "value": str(_port_from_url(config.FASTER_API_URL, 8000)), "validate": _validate_port}, - {"key": "qwen_custom_port", "label": "qwen-tts CustomVoice port", + {"key": "qwen_port", "label": "qwen-tts port", "kind": "text", "value": str(_port_from_url(config.QWEN_API_URL, 7860)), "validate": _validate_port}, - {"key": "qwen_clone_port", "label": "qwen-tts Base port", - "kind": "text", - "value": str(_port_from_url(config.CLONE_API_URL, 7861)), - "validate": _validate_port}, {"key": "audiocpp_remote_url", "label": "audio.cpp remote URL", "kind": "text", "value": config.AUDIOCPP_REMOTE_URL, @@ -1420,14 +1451,10 @@ def _settings_fields() -> list: "kind": "text", "value": config.FASTER_REMOTE_URL, "validate": _validate_remote_url}, - {"key": "qwen_custom_remote_url", "label": "qwen-tts CustomVoice remote URL", + {"key": "qwen_remote_url", "label": "qwen-tts remote URL", "kind": "text", "value": config.QWEN_REMOTE_URL, "validate": _validate_remote_url}, - {"key": "qwen_clone_remote_url", "label": "qwen-tts Base remote URL", - "kind": "text", - "value": config.CLONE_REMOTE_URL, - "validate": _validate_remote_url}, ] @@ -1507,16 +1534,13 @@ def _apply_settings(values: dict) -> None: raise ValueError(f"Unsupported audio format: {values['audio_format']}") ports = { - "qwen_custom_port": _read_port(values, "qwen_custom_port"), - "qwen_clone_port": _read_port(values, "qwen_clone_port"), + "qwen_port": _read_port(values, "qwen_port"), "faster_port": _read_port(values, "faster_port"), "audiocpp_port": _read_port(values, "audiocpp_port"), } remote_urls = { "QWEN_REMOTE_URL": common.normalize_remote_url( - values.get("qwen_custom_remote_url", "")), - "CLONE_REMOTE_URL": common.normalize_remote_url( - values.get("qwen_clone_remote_url", "")), + values.get("qwen_remote_url", "")), "FASTER_REMOTE_URL": common.normalize_remote_url( values.get("faster_remote_url", "")), "AUDIOCPP_REMOTE_URL": common.normalize_remote_url( @@ -1530,9 +1554,7 @@ def _apply_settings(values: dict) -> None: "STOP_SERVER_AND_EXIT": bool(values["stop_and_exit"]), "AUDIOCPP_UNLOAD_MODELS": bool(values["unload_models"]), "QWEN_API_URL": common.url_with_port( - config.QWEN_API_URL, ports["qwen_custom_port"]), - "CLONE_API_URL": common.url_with_port( - config.CLONE_API_URL, ports["qwen_clone_port"]), + config.QWEN_API_URL, ports["qwen_port"]), "FASTER_API_URL": common.url_with_port( config.FASTER_API_URL, ports["faster_port"]), "AUDIOCPP_API_URL": common.url_with_port( @@ -1607,6 +1629,9 @@ def _prepare_run_config(backend: str, kwargs: dict LOGS_FOLDER.mkdir(parents=True, exist_ok=True) Path(log_path).touch() autostart = kwargs.pop("autostart", None) + # A running managed qwen server hosting another model than the run's + # selection: stop it and boot the new model before converting. + restart_name = kwargs.pop("restart_server", None) # The run-view behavior toggle (not a converter kwarg): stop the server # and quit the TUI once the generation ends. stop_and_exit = bool(kwargs.pop("stop_and_exit", False)) @@ -1641,57 +1666,95 @@ def _prepare_run_config(backend: str, kwargs: dict # The recorded server vanished (backend reconfigured meanwhile): # converting without it is still meaningful, so continue. notice = (f"no server named '{autostart}' — starting it was skipped") + if restart_name and spec is None: + spec = _find_spec(restart_name) + if spec is None: + notice = (f"no server named '{restart_name}' — the model " + "switch restart was skipped") return runview.RunConfig( backend=backend, backend_label=label, kwargs=kwargs, book_files=book_files, planned=planned, server_name=spec.name if spec is not None else None, server_url=spec.url if spec is not None else None, server_identity=spec.identity if spec is not None else None, - autostart_spec=spec if autostart else None, + autostart_spec=spec if (autostart or restart_name) else None, + restart_first=bool(restart_name) and spec is not None, log_path=log_path, notice=notice, stop_and_exit=stop_and_exit) +def _qwen_wanted_model(kwargs: dict) -> str: + """The qwen model a conversion with these kwargs needs hosted. + + One demo server hosts one model; the selected voice mode picks it + (mirrors ``voice_mode_for``): instructions design the voice (VoiceDesign), + a reference .wav clones (Base), otherwise built-in speakers (CustomVoice). + """ + if (kwargs.get("instructions") or "").strip(): + return "VoiceDesign" + return "Base" if kwargs.get("clone") else "CustomVoice" + + def _remote_identity(backend: str, kwargs: dict) -> Optional[str]: """The probe identity of the remote server a conversion targets.""" if backend == BACKEND_AUDIOCPP: return backend_probe.IDENTITY_AUDIOCPP if backend == BACKEND_QWEN: - return backend_probe.IDENTITY_QWEN_CLONE if kwargs.get("clone") \ - else backend_probe.IDENTITY_QWEN_CUSTOM + wanted = _qwen_wanted_model(kwargs) + return {"CustomVoice": backend_probe.IDENTITY_QWEN_CUSTOM, + "Base": backend_probe.IDENTITY_QWEN_CLONE, + "VoiceDesign": backend_probe.IDENTITY_QWEN_DESIGN}[wanted] if backend == BACKEND_FASTER: return backend_probe.IDENTITY_FASTER return None -def _add_autostart(cmd: tuple, statuses) -> None: +def _add_autostart(cmd: tuple, statuses) -> Optional[str]: """Auto-start the conversion's target server when it isn't running. Records the chosen server spec name as ``kwargs['autostart']`` for ``_prepare_run_config`` to act on. The user already accepted the run on the Generate! screen, so no start-server prompt is asked here — the - server is simply started. Mode-aware for qwen (custom vs clone). - Remote conversions (an ``api_url`` in the kwargs) never autostart: the - server is external to this tool. + server is simply started. Remote conversions (an ``api_url`` in the + kwargs) never autostart: the server is external to this tool. + + The single-port qwen backend additionally checks the model the running + server hosts against the one this run selected: a managed server hosting + another model is recorded in ``kwargs['restart_server']`` (stopped and + rebooted with the new model before converting), while a foreign server + with the wrong model refuses the run — an explanatory message is + returned for the caller to flash. Returns None when no message is owed. """ _, key, kwargs = cmd if kwargs.get("api_url"): - return + return None status = next((s for s in statuses if s.key == key), None) if status is None or not status.servers: - return + return None spec = _select_spec(status, kwargs) if spec is None: - return - if common.server_running(spec.url): - return - kwargs["autostart"] = spec.name + return None + if not common.server_running(spec.url): + kwargs["autostart"] = spec.name + return None + if status.key != BACKEND_QWEN or len(status.servers) != 1: + return None + wanted = _qwen_wanted_model(kwargs) + running = qwen_backend.model_for_identity( + backend_probe.identify_server(spec.url)) + if running == wanted: + return None + if servers.alive(spec.name): + # Ours: the run view stops it and boots the newly-selected model. + kwargs["restart_server"] = spec.name + return None + return (f"a server this tool did not start is running at {spec.url} " + f"hosting {running or 'an unknown'} — this run needs " + f"{wanted}. Stop that server first, or convert with it by " + f"picking {running} as the Model.") def _select_spec(status, kwargs) -> Optional[ServerSpec]: - """The server spec this conversion needs (mode-aware for qwen).""" - if status.key == BACKEND_QWEN: - wanted = "qwen-clone" if kwargs.get("clone") else "qwen-custom" - return next((s for s in status.servers if s.name == wanted), None) + """The server spec this conversion needs (qwen has exactly one).""" return status.servers[0] if status.servers else None diff --git a/app/ui/runview.py b/app/ui/runview.py index 1da8690..9448c06 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -72,13 +72,17 @@ class RunConfig: conversion talks to (the name is the backends.ServerSpec name; the URL is what the monitor polls). AUTOSTART_SPEC, when not None, is the ServerSpec the worker boots first (the hub only sets it when the - server is not already running). LOG_PATH names the converter's log - file for the error screen's "details" hint. NOTICE is an optional - warning line shown under the progress panel (e.g. a foreign server - holding the managed port). STOP_AND_EXIT ("Stop server and exit after - generating") skips the finished screen entirely: the server is stopped - automatically, the TUI quits, and the results are printed to the real - terminal after curses closes. + server is not already running). RESTART_FIRST marks an autostart of a + different kind: the managed qwen server is up but hosting another + model than this run selected, so the worker stops it and boots the + spec again (with the new model's argv) before converting. LOG_PATH + names the converter's log file for the error screen's "details" hint. + NOTICE is an optional warning line shown under the progress panel + (e.g. a foreign server holding the managed port). + STOP_AND_EXIT ("Stop server and exit after generating") skips the + finished screen entirely: the server is stopped automatically, the + TUI quits, and the results are printed to the real terminal after + curses closes. """ backend: str backend_label: str @@ -89,6 +93,7 @@ class RunConfig: server_url: Optional[str] = None server_identity: Optional[str] = None autostart_spec: object = None + restart_first: bool = False log_path: str = "" notice: str = "" stop_and_exit: bool = False @@ -229,6 +234,11 @@ class RunView(ScreenView): try: with contextlib.redirect_stdout(io.StringIO()): if config.autostart_spec is not None: + if config.restart_first: + # The managed qwen server hosts another model than + # this run selected: stop it so the new model's + # argv can boot on the same port. + servers.stop(config.autostart_spec.name) ok = servers.start(config.autostart_spec, progress=self._queue.put, cancel=self._cancel) diff --git a/audiobook.py b/audiobook.py index f95e1d3..97171ac 100755 --- a/audiobook.py +++ b/audiobook.py @@ -42,6 +42,7 @@ from converter.clients import ( BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, + VOICE_MODE_DESIGN, normalize_language, ) from converter.converter import ( @@ -95,7 +96,12 @@ def convert(backend: str = None, voice: str = None, clone: str = None, elif backend == BACKEND_AUDIOCPP: voice_mode = VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM else: - voice_mode = VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM + # qwen: instructions design the voice (VoiceDesign model), a + # reference .wav clones one (Base), otherwise a built-in speaker. + if (instructions or "").strip(): + voice_mode = VOICE_MODE_DESIGN + else: + voice_mode = VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM if book_files is None or planned is None: book_files, planned = AudiobookConverter.preflight_overwrites( @@ -181,6 +187,10 @@ Examples: # Use the qwen-tts demo server with voice cloning from reference audio python audiobook.py --backend qwen --clone path/to/reference.wav + # Use the qwen-tts demo server with a designed voice (VoiceDesign model) + python audiobook.py --backend qwen \\ + --instructions "A warm adult female narrator with a British accent" + # Use the faster-qwen3-tts server (voice cloning, configured server-side) python audiobook.py --backend faster [--voice NAME] """ @@ -276,13 +286,13 @@ Examples: parser.add_argument( "--instructions", type=str, default=None, metavar="TEXT", help=("Voice design or style instruction sent with every request " - "(--backend audiocpp only). Required for voice design models " - "(server entries with task 'vdes', e.g. Qwen3-TTS " - "VoiceDesign): describe the voice to synthesize with, e.g. " - "'A warm adult female narrator with a British accent'. On " - "other families it acts as a style/delivery instruction when " - "the model supports one and is ignored otherwise. Defaults to " - "AUDIOCPP_INSTRUCTIONS in app/converter/config.py (empty).") + "(--backend audiocpp or qwen). With audiocpp it is required " + "for voice design models (server entries with task 'vdes', " + "e.g. Qwen3-TTS VoiceDesign) and optional style/delivery " + "control elsewhere; with qwen it selects the VoiceDesign " + "model and describes the voice to synthesize with, e.g. " + "'A warm adult female narrator with a British accent' " + "(defaulting to INSTRUCT in app/converter/config.py).") ) parser.add_argument( "--option", action="append", type=str, default=None, metavar="KEY=VALUE", @@ -362,9 +372,10 @@ Examples: parser.error("--model requires --backend audiocpp; it selects an " "audio.cpp server model entry id") - if args.instructions is not None and args.backend != BACKEND_AUDIOCPP: - parser.error("--instructions requires --backend audiocpp; it is " - "sent as the audio.cpp request's instructions field") + if args.instructions is not None and args.backend not in \ + (BACKEND_AUDIOCPP, BACKEND_QWEN): + parser.error("--instructions requires --backend audiocpp or qwen; " + "it is sent as the request's voice design/style field") request_options = {} if args.option: -- cgit v1.2.3