Replace XTTS with Qwen3-TTS
This commit is contained in:
@@ -110,6 +110,22 @@ class LanguageSegmentationTests(unittest.TestCase):
|
||||
self.assertIn("29 Kilometer pro Stunde", spoken)
|
||||
self.assertNotIn("km", spoken.lower())
|
||||
|
||||
def test_qwen_normalizes_ipv4_time_date_and_count(self):
|
||||
spoken = gateway.prepare_for_qwen_speech(
|
||||
"full_kiosk (192.168.1.5): 2× Timeout um 02:14 seit 01.09."
|
||||
)
|
||||
self.assertIn("full kiosk", spoken)
|
||||
self.assertIn("192 Punkt 168 Punkt 1 Punkt 5", spoken)
|
||||
self.assertIn("2 mal Timeout", spoken)
|
||||
self.assertIn("2 Uhr 14", spoken)
|
||||
self.assertIn("1. September", spoken)
|
||||
|
||||
def test_qwen_keeps_prosody_punctuation(self):
|
||||
spoken = gateway.prepare_for_qwen_speech(
|
||||
"Ist das gut? Ja! SarahTV: erreichbar."
|
||||
)
|
||||
self.assertEqual(spoken, "Ist das gut? Ja! SarahTV: erreichbar.")
|
||||
|
||||
def test_visual_punctuation_becomes_natural_pauses(self):
|
||||
spoken = gateway.clean_for_speech(
|
||||
"Status: stabil – keine Fehler; Docker-Container laufen."
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Private XTTS-first TTS gateway with a Piper fallback.
|
||||
"""Private Qwen3-TTS-first gateway with a Piper fallback.
|
||||
|
||||
The gateway implements the narrow /status and /tts protocol already consumed
|
||||
by the profile router. Request text is never logged or persisted.
|
||||
@@ -25,6 +25,11 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
HOST = os.getenv("TTS_GATEWAY_HOST", "0.0.0.0")
|
||||
PORT = int(os.getenv("TTS_GATEWAY_PORT", "8085"))
|
||||
QWEN_TTS_URL = os.getenv("QWEN_TTS_URL", "http://qwen3-tts:8001").rstrip("/")
|
||||
QWEN_TTS_MODEL = os.getenv("QWEN_TTS_MODEL", "tts-1")
|
||||
QWEN_TTS_VOICE = os.getenv("QWEN_TTS_VOICE", "serena")
|
||||
QWEN_TTS_LANGUAGE = os.getenv("QWEN_TTS_LANGUAGE", "German")
|
||||
QWEN_TTS_TIMEOUT = float(os.getenv("QWEN_TTS_TIMEOUT", "120"))
|
||||
XTTS_URL = os.getenv("XTTS_URL", "http://xtts:80").rstrip("/")
|
||||
PIPER_URL = os.getenv("PIPER_URL", "http://piper:8085").rstrip("/")
|
||||
VOICE_ALIAS = os.getenv("TTS_VOICE_ALIAS", "alloy")
|
||||
@@ -223,8 +228,25 @@ def _spell_digits(value: str) -> str:
|
||||
return " ".join(GERMAN_DIGITS[digit] for digit in value)
|
||||
|
||||
|
||||
def _spoken_ipv4(match: re.Match) -> str:
|
||||
"""Keep IPv4 octets intact while making the separators pronounceable."""
|
||||
return " Punkt ".join(str(int(part)) for part in match.group(0).split("."))
|
||||
|
||||
|
||||
def normalize_for_german_speech(text: str) -> str:
|
||||
"""Turn common visual notation into unambiguous spoken German."""
|
||||
# Run these before the date rule: otherwise 192.168.1.5 could be partly
|
||||
# interpreted as a visual date.
|
||||
text = re.sub(
|
||||
r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
|
||||
_spoken_ipv4,
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
r"\b([01]?\d|2[0-3]):([0-5]\d)\b",
|
||||
lambda match: f"{int(match.group(1))} Uhr {int(match.group(2))}",
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
r"(-?\d+(?:[,.]\d+)?)[ \t]*°[ \t]*(?:C)?[ \t]*/[ \t]*"
|
||||
r"(-?\d+(?:[,.]\d+)?)[ \t]*°[ \t]*(?:C)?",
|
||||
@@ -281,9 +303,31 @@ def normalize_for_german_speech(text: str) -> str:
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(r"(\d)\s*%", r"\1 Prozent", text)
|
||||
text = re.sub(r"\b(\d+)\s*[×x]\s*", r"\1 mal ", text)
|
||||
return text
|
||||
|
||||
|
||||
def prepare_for_qwen_speech(text: str) -> str:
|
||||
"""Normalize technical display text without destroying Qwen's prosody."""
|
||||
text = re.sub(r"```.*?```", " Codeblock. ", text, flags=re.DOTALL)
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
text = re.sub(r"!\[([^]]*)\]\([^)]+\)", r"\1", text)
|
||||
text = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", text)
|
||||
text = re.sub(r"(?m)^\s{0,3}#{1,6}\s*", "", text)
|
||||
text = re.sub(r"(?m)^\s*[-*+]\s+", "", text)
|
||||
text = text.replace("_", " ").replace("/", ", ")
|
||||
text = text.replace("→", ". ").replace("←", ". ")
|
||||
if DEFAULT_LANGUAGE == "de":
|
||||
text = normalize_for_german_speech(text)
|
||||
text = "".join(
|
||||
char for char in text
|
||||
if unicodedata.category(char) not in {"So", "Cs"}
|
||||
)
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
text = re.sub(r"\s*\n+\s*", ". ", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def clean_for_speech(text: str) -> str:
|
||||
"""Remove visual markup that makes long TTS output unstable or noisy."""
|
||||
text = re.sub(r"```.*?```", " Code block. ", text, flags=re.DOTALL)
|
||||
@@ -553,8 +597,17 @@ def _wav(pcm: bytes) -> bytes:
|
||||
def _convert(wav_bytes: bytes, output_format: str, speed: float) -> tuple[bytes, str]:
|
||||
if output_format == "wav" and speed == 1.0:
|
||||
return wav_bytes, "audio/wav"
|
||||
codec = ["-codec:a", "libmp3lame", "-b:a", "96k", "-f", "mp3"] \
|
||||
if output_format == "mp3" else ["-codec:a", "pcm_s16le", "-f", "wav"]
|
||||
if output_format == "mp3":
|
||||
codec = ["-codec:a", "libmp3lame", "-b:a", "96k", "-f", "mp3"]
|
||||
content_type = "audio/mpeg"
|
||||
elif output_format == "pcm":
|
||||
# Hermes' OpenAI streaming TTS client expects headerless 24 kHz,
|
||||
# mono, signed 16-bit little-endian PCM chunks.
|
||||
codec = ["-ac", "1", "-ar", "24000", "-codec:a", "pcm_s16le", "-f", "s16le"]
|
||||
content_type = "application/octet-stream"
|
||||
else:
|
||||
codec = ["-codec:a", "pcm_s16le", "-f", "wav"]
|
||||
content_type = "audio/wav"
|
||||
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "wav",
|
||||
"-i", "pipe:0"]
|
||||
if speed != 1.0:
|
||||
@@ -565,7 +618,7 @@ def _convert(wav_bytes: bytes, output_format: str, speed: float) -> tuple[bytes,
|
||||
check=False, timeout=120)
|
||||
if result.returncode != 0 or not result.stdout:
|
||||
raise RuntimeError("audio conversion failed")
|
||||
return result.stdout, "audio/mpeg" if output_format == "mp3" else "audio/wav"
|
||||
return result.stdout, content_type
|
||||
|
||||
|
||||
def synthesize_xtts(text: str, output_format: str,
|
||||
@@ -583,24 +636,40 @@ def synthesize_xtts(text: str, output_format: str,
|
||||
|
||||
def synthesize_piper(text: str, output_format: str,
|
||||
speed: float) -> tuple[bytes, str]:
|
||||
return _request(
|
||||
upstream_format = "wav" if output_format == "pcm" else output_format
|
||||
audio, content_type = _request(
|
||||
f"{PIPER_URL}/tts",
|
||||
payload={"text": text, "voice": "alloy", "speed": speed,
|
||||
"format": output_format},
|
||||
"format": upstream_format},
|
||||
timeout=PIPER_TIMEOUT,
|
||||
)
|
||||
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
||||
|
||||
|
||||
def synthesize_qwen(text: str, output_format: str,
|
||||
speed: float) -> tuple[bytes, str]:
|
||||
text = prepare_for_qwen_speech(text)
|
||||
upstream_format = "wav" if output_format == "pcm" else output_format
|
||||
audio, content_type = _request(
|
||||
f"{QWEN_TTS_URL}/v1/audio/speech",
|
||||
payload={"model": QWEN_TTS_MODEL, "input": text,
|
||||
"voice": QWEN_TTS_VOICE, "language": QWEN_TTS_LANGUAGE,
|
||||
"response_format": upstream_format, "speed": speed},
|
||||
timeout=QWEN_TTS_TIMEOUT,
|
||||
)
|
||||
return _convert(audio, "pcm", 1.0) if output_format == "pcm" else (audio, content_type)
|
||||
|
||||
|
||||
def synthesize(text: str, output_format: str, speed: float) -> tuple[bytes, str]:
|
||||
acquired = SYNTHESIS_LOCK.acquire(timeout=QUEUE_TIMEOUT)
|
||||
if acquired:
|
||||
try:
|
||||
audio = synthesize_xtts(text, output_format, speed)
|
||||
audio = synthesize_qwen(text, output_format, speed)
|
||||
with STATE_LOCK:
|
||||
STATE["last_backend"] = "xtts-v2"
|
||||
STATE["last_backend"] = "qwen3-tts-1.7b"
|
||||
STATE["last_error"] = None
|
||||
return audio
|
||||
except Exception as exc: # fallback must cover all XTTS failures
|
||||
except Exception as exc: # fallback must cover all Qwen failures
|
||||
with STATE_LOCK:
|
||||
STATE["xtts_failures"] += 1
|
||||
STATE["last_error"] = type(exc).__name__
|
||||
@@ -641,7 +710,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path != "/status":
|
||||
self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"})
|
||||
return
|
||||
primary_ready = _reachable(XTTS_URL, "/languages")
|
||||
primary_ready = _reachable(QWEN_TTS_URL, "/health")
|
||||
fallback_ready = _reachable(PIPER_URL, "/status")
|
||||
with STATE_LOCK:
|
||||
state = dict(STATE)
|
||||
@@ -649,10 +718,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.OK if fallback_ready else HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
{
|
||||
"ready": fallback_ready,
|
||||
"engine": "xtts-v2-with-piper-fallback",
|
||||
"model": "xtts-v2",
|
||||
"engine": "qwen3-tts-with-piper-fallback",
|
||||
"model": "Qwen3-TTS-12Hz-1.7B-Base",
|
||||
"voices": [VOICE_ALIAS],
|
||||
"speaker": XTTS_SPEAKER,
|
||||
"speaker": QWEN_TTS_VOICE,
|
||||
"primary_ready": primary_ready,
|
||||
"fallback_ready": fallback_ready,
|
||||
**state,
|
||||
@@ -686,7 +755,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if voice != VOICE_ALIAS:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unknown voice"})
|
||||
return
|
||||
if output_format not in {"wav", "mp3"}:
|
||||
if output_format not in {"wav", "mp3", "pcm"}:
|
||||
self.send_json(HTTPStatus.BAD_REQUEST, {"error": "unsupported format"})
|
||||
return
|
||||
if not 0.5 <= speed <= 2.0:
|
||||
@@ -707,5 +776,5 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"TTS gateway ready on {HOST}:{PORT}; primary={XTTS_SPEAKER}; fallback=Piper")
|
||||
print(f"TTS gateway ready on {HOST}:{PORT}; primary={QWEN_TTS_VOICE}; fallback=Piper")
|
||||
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
|
||||
|
||||
Reference in New Issue
Block a user