aboutsummaryrefslogtreecommitdiff
path: root/app/converter/clients
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter/clients')
-rw-r--r--app/converter/clients/__init__.py4
-rw-r--r--app/converter/clients/base.py10
-rw-r--r--app/converter/clients/qwen.py54
3 files changed, 50 insertions, 18 deletions
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: