@@ -1,5 +1,5 @@
#!/usr/bin/env python3
""" Private X TTS-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 ( )