feat(tts): Emojis/Markdown beim Vorlesen entfernen + Autostart/GPU-Doku

- spoken_response_adapter: Emoji-/Symbol-Strip (Emoticons, Symbole, Flaggen,
  Pfeile, Dingbats, ZWJ/Variationsselektoren). Markdown wurde bereits entfernt.
- Geräte-TTS (app.js v28) liest das bereinigte "spoken"-Feld statt des Rohtexts
  (automatisch + beim Replay); Anzeige bleibt Originaltext.
- deploy/ollama-keepalive.conf: Drop-in OLLAMA_KEEP_ALIVE=5m -> GPU im Leerlauf frei.
- Doku §4.10: Dauerbetrieb als Dienst + warum der Gateway die GPU nicht blockiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-20 18:25:45 +02:00
commit bd20a308de
5 changed files with 85 additions and 5 deletions

View file

@ -2,6 +2,22 @@ import re
from app.pipeline import german_numbers as gn
# Emojis/Piktogramme/Symbole: stoeren das Vorlesen (TTS spricht sie aus oder verschluckt
# sich). Deckt die gaengigen Unicode-Bloecke ab (Emoticons, Symbole, Transport, Flaggen,
# Dingbats, Pfeile, Variationsselektoren, ZWJ).
_EMOJI_RE = re.compile(
"["
"\U0001F300-\U0001FAFF" # Symbole/Emoticons/Transport/Erweiterungen
"\U00002600-\U000027BF" # Diverse Symbole + Dingbats (☀ ⚠ ✅ ❤ …)
"\U0001F1E6-\U0001F1FF" # Regional-Indikatoren (Flaggen)
"\U00002B00-\U00002BFF" # Symbole/Pfeile (⭐ …)
"\U00002190-\U000021FF" # Pfeile (→ ← …)
"\U00002300-\U000023FF" # Technische Symbole (⌚ ⏰ …)
"\U0000FE00-\U0000FE0F" # Variationsselektoren
"\U0000200D" # Zero-Width-Joiner
"]+"
)
class SpokenResponseAdapter:
async def run(self, text: str, language: str = "de") -> str:
@ -15,6 +31,7 @@ class SpokenResponseAdapter:
text = re.sub(r"`([^`]*)`", r"\1", text) # inline code
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # markdown links
text = re.sub(r"[*_~#>]+", " ", text) # markdown symbols
text = _EMOJI_RE.sub("", text) # Emojis/Symbole
# Listen entschärfen: Aufzählungspunkte weg, nummerierte Listen zu
# Ordinalwörtern ("1. " -> "erstens, "), damit Piper nicht "eins" sagt.

View file

@ -280,9 +280,10 @@ function replayBubble(div) {
startReplay(info.chunks, btn); // Assistent: gecachtes PCM, sofort & gratis
} else if (isDeviceMode()) {
stopAudio();
deviceSpeakBtn(text, info.lang, btn); // Geräte-TTS: lokal vorlesen, gratis
// Geräte-TTS: bereinigtes "spoken" bevorzugen (ohne Markdown/Emojis), sonst Rohtext.
deviceSpeakBtn(info.spoken || text, info.lang, btn);
} else {
speakBubble(text, info.lang, btn); // Server-TTS via /api/speak
speakBubble(text, info.lang, btn); // Server-TTS via /api/speak (bereinigt selbst)
}
}
@ -385,8 +386,16 @@ function runTurn(path, onopen) {
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
if (msg.text) answerEl._textEl.textContent = msg.text;
// Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen.
if (answerEl._replay) answerEl._replay.lang = turnLang();
if (isDeviceMode() && msg.text) deviceSpeak(msg.text, turnLang());
// Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen
// bleibt der Originaltext.
if (answerEl._replay) {
answerEl._replay.lang = turnLang();
answerEl._replay.spoken = msg.spoken || msg.text || "";
}
if (isDeviceMode()) {
const spoken = msg.spoken || msg.text;
if (spoken) deviceSpeak(spoken, turnLang());
}
scrollToBottom();
break;
case "emergency":

View file

@ -250,6 +250,6 @@
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
</footer>
<script src="/app.js?v=27"></script>
<script src="/app.js?v=28"></script>
</body>
</html>