feat: TTS-Zeitbereiche + Diagnostic-Debug-Toggle + Play-Button respektiert Engine

TTS-Cleanup erweitert:
- Zeitbereiche: '8:00-9:00 Uhr' / '8-9 Uhr' → 'acht bis neun Uhr'
- Uhrzeiten: '8:30 Uhr' → 'acht Uhr dreissig', '15 Uhr' → 'fuenfzehn Uhr'
- Kleine Zahlen-Bereiche: '5-6' → 'fuenf bis sechs' (nur ≤24)
- Zahlen 0-59 als deutsche Woerter (inkl. 'einundzwanzig', 'fuenfundvierzig')

Diagnostic: TTS-Debug Einblenden
- Checkbox 'TTS-Text einblenden' in der Chat-Test Kopfzeile
- Unter ARIA-Nachrichten erscheint die aufbereitete Variante
  (blauer Border + Label 'TTS:')
- Nur in Diagnostic, nicht in der App
- LocalStorage persistiert den Toggle-Zustand
- Minimaler JS-Port von clean_text_for_tts als Fallback

Play-Button respektiert Engine:
- Bridge: tts_request nutzt jetzt die aktive TTS-Engine (Piper/XTTS),
  Text wird durch clean_text_for_tts aufbereitet
- messageId wird vom Play-Button mitgeschickt → Bridge verknuepft
  generiertes Audio mit der urspruenglichen Message
- XTTS-Chunks: requestId → messageId Map (LRU 100 Eintraege),
  beim xtts_response wird die Basis-UUID extrahiert und die
  messageId dem audio-Frame angehaengt
- App cached auch XTTS-Audio jetzt (letzter Satz pro Message —
  echte Chunk-Konkatenation bleibt TODO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-19 21:48:32 +02:00
parent 1fb1fdef9e
commit eb12281dfc
3 changed files with 170 additions and 10 deletions
+107 -4
View File
@@ -145,6 +145,46 @@ def load_config() -> dict[str, str]:
import re as _re_tts
_NUM_WORDS_DE = {
0: "null", 1: "eins", 2: "zwei", 3: "drei", 4: "vier", 5: "fuenf",
6: "sechs", 7: "sieben", 8: "acht", 9: "neun", 10: "zehn",
11: "elf", 12: "zwoelf", 13: "dreizehn", 14: "vierzehn", 15: "fuenfzehn",
16: "sechzehn", 17: "siebzehn", 18: "achtzehn", 19: "neunzehn", 20: "zwanzig",
}
_TENS_DE = {30: "dreissig", 40: "vierzig", 50: "fuenfzig"}
def _num_to_words_de(n: int) -> str:
"""Zahlen 0-59 als deutsches Wort — fuer Uhrzeiten und kleine Bereiche."""
if n in _NUM_WORDS_DE:
return _NUM_WORDS_DE[n]
if 21 <= n <= 29:
return f"{_NUM_WORDS_DE[n - 20]}undzwanzig"
if 30 <= n <= 59:
tens = (n // 10) * 10
ones = n % 10
tens_word = _TENS_DE.get(tens, str(tens))
if ones == 0:
return tens_word
return f"{_NUM_WORDS_DE.get(ones, str(ones))}und{tens_word}"
return str(n)
def _time_range_to_words(m):
"""'8:00-9:00 Uhr''acht bis neun Uhr', '8-9 Uhr''acht bis neun Uhr'."""
h1 = int(m.group(1))
h2 = int(m.group(3))
return f"{_num_to_words_de(h1)} bis {_num_to_words_de(h2)} Uhr"
def _small_range_to_words(m):
"""'5-6''fuenf bis sechs' (nur wenn beide Zahlen ≤ 24)."""
a, b = int(m.group(1)), int(m.group(2))
if a > 24 or b > 24 or a >= b:
return m.group(0)
return f"{_num_to_words_de(a)} bis {_num_to_words_de(b)}"
_UNIT_WORDS = [
(r'\bTB\b', 'Terabyte'),
(r'\bGB\b', 'Gigabyte'),
@@ -215,6 +255,22 @@ def clean_text_for_tts(text: str) -> str:
t = _re_tts.sub(r'^>\s*', '', t, flags=_re_tts.MULTILINE)
t = _re_tts.sub(r'^[\-\*]\s+', '', t, flags=_re_tts.MULTILINE)
# Zeitbereiche: "8:00-9:00 Uhr" / "8-9 Uhr" → "acht bis neun Uhr"
t = _re_tts.sub(r'\b(\d{1,2})(:\d{2})?\s*[-]\s*(\d{1,2})(:\d{2})?\s*Uhr\b', _time_range_to_words, t)
# Uhrzeiten mit Minuten: "8:30 Uhr" → "acht Uhr dreissig", "8:00 Uhr" → "acht Uhr"
def _single_time(m):
h = int(m.group(1))
mn = int(m.group(2)) if m.group(2) else 0
words = _num_to_words_de(h) + " Uhr"
if mn > 0:
words += " " + _num_to_words_de(mn)
return words
t = _re_tts.sub(r'\b(\d{1,2}):(\d{2})\s*Uhr\b', _single_time, t)
# Volle Uhrzeiten ohne ":" — "15 Uhr" → "fuenfzehn Uhr"
t = _re_tts.sub(r'\b(\d{1,2})\s+Uhr\b', lambda m: f"{_num_to_words_de(int(m.group(1)))} Uhr", t)
# Kleine Zahlen-Bereiche ohne "Uhr": "5-6" → "fuenf bis sechs"
t = _re_tts.sub(r'\b(\d{1,2})\s*[-]\s*(\d{1,2})\b', _small_range_to_words, t)
# Zahlen + Einheit: "22GB" → "22 Gigabyte" (Leerzeichen einfuegen)
t = _re_tts.sub(r'(\d+)([A-Za-z]{1,4})\b', r'\1 \2', t)
@@ -655,6 +711,8 @@ class ARIABridge:
# Zeitstempel des letzten chat:final — waehrend 3s danach werden
# trailing Agent-Events unterdrueckt (Core raeumt manchmal nach).
self._last_chat_final_at: float = 0.0
# requestId → messageId Map fuer XTTS-Audio-Cache (App-seitige Zuordnung)
self._xtts_request_to_message: dict[str, str] = {}
def initialize(self) -> None:
"""Initialisiert alle Komponenten.
@@ -998,6 +1056,9 @@ class ARIABridge:
# Eindeutige Message-ID fuer Audio-Cache-Zuordnung
message_id = str(uuid.uuid4())
# TTS-aufbereitete Variante fuer Debug (Diagnostic zeigt optional)
tts_text_preview = clean_text_for_tts(text)
# Antwort an die App weiterleiten (als Chat-Nachricht)
await self._send_to_rvs({
"type": "chat",
@@ -1006,6 +1067,8 @@ class ARIABridge:
"sender": "aria",
"voice": voice_name,
"messageId": message_id,
# Debug: aufbereiteter Text fuer TTS (App ignoriert, Diagnostic zeigt optional)
"ttsText": tts_text_preview if tts_text_preview != text else "",
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -1022,13 +1085,20 @@ class ARIABridge:
logger.info("[core] TTS-Text leer nach Cleanup — XTTS uebersprungen")
return
try:
xtts_request_id = str(uuid.uuid4())
# Map fuer xtts_response → App-Cache Zuordnung
self._xtts_request_to_message[xtts_request_id] = message_id
if len(self._xtts_request_to_message) > 100:
# Oldest entry raus damit der Dict nicht waechst
oldest = next(iter(self._xtts_request_to_message))
self._xtts_request_to_message.pop(oldest, None)
await self._send_to_rvs({
"type": "xtts_request",
"payload": {
"text": tts_text,
"voice": xtts_voice,
"language": "de",
"requestId": str(uuid.uuid4()),
"requestId": xtts_request_id,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
@@ -1230,6 +1300,10 @@ class ARIABridge:
# XTTS-Audio vom Gaming-PC empfangen → an App weiterleiten
audio_b64 = payload.get("base64", "")
error = payload.get("error", "")
req_id_full = payload.get("requestId", "")
# XTTS-Bridge suffixt chunkweise: "uuid_0", "uuid_1" → Basis-UUID extrahieren
req_id_base = req_id_full.rsplit("_", 1)[0] if "_" in req_id_full else req_id_full
linked_message_id = self._xtts_request_to_message.get(req_id_base, "")
if error:
logger.warning("[rvs] XTTS Fehler: %s", error)
return
@@ -1241,16 +1315,44 @@ class ARIABridge:
"base64": audio_b64,
"mimeType": payload.get("mimeType", "audio/wav"),
"voice": payload.get("voice", "xtts"),
"messageId": linked_message_id,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
return
elif msg_type == "tts_request":
# App fordert TTS-Audio fuer einen Text an (Play-Button)
# App fordert TTS-Audio fuer einen Text an (Play-Button).
# Nutze die aktuell konfigurierte Engine (Piper oder XTTS).
text = payload.get("text", "")
requested_voice = payload.get("voice", "")
if text:
message_id = payload.get("messageId", "") # fuer Cache-Zuordnung
if not text:
return
tts_engine = getattr(self, 'tts_engine_type', 'piper')
tts_text = clean_text_for_tts(text) or text
if tts_engine == "xtts":
xtts_voice = getattr(self, 'xtts_voice', '')
try:
await self._send_to_rvs({
"type": "xtts_request",
"payload": {
"text": tts_text,
"voice": xtts_voice,
"language": "de",
"requestId": str(uuid.uuid4()),
"messageId": message_id,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[rvs] TTS on-demand via XTTS: '%s'", tts_text[:60])
except Exception as e:
logger.warning("[rvs] XTTS-Request fehlgeschlagen, Fallback Piper: %s", e)
tts_engine = "piper"
if tts_engine == "piper":
voice_name = requested_voice or self.voice_engine.select_voice(text)
audio_data = self.voice_engine.synthesize(text, voice_name)
if audio_data:
@@ -1262,10 +1364,11 @@ class ARIABridge:
"base64": audio_b64,
"mimeType": "audio/wav",
"voice": voice_name,
"messageId": message_id,
},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[rvs] TTS on-demand: %d bytes (%s)", len(audio_data), voice_name)
logger.info("[rvs] TTS on-demand via Piper: %d bytes (%s)", len(audio_data), voice_name)
except Exception as e:
logger.warning("[rvs] TTS on-demand senden fehlgeschlagen: %s", e)
return