From bed29fb1c8e2e7b320cf6dcf03a4cf9ff913bbd9 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 16 May 2026 08:56:50 +0200 Subject: [PATCH 1/4] Initial commit: chatterbox TTS CLI v4 --- .gitignore | 27 + BEDIENUNGSANLEITUNG.md | 200 ++++++++ README.md | 139 ++++++ Trump_in_China_kurz.txt | 18 + chatterbox_cli_v4.py | 1033 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 17 + 6 files changed, 1434 insertions(+) create mode 100644 .gitignore create mode 100644 BEDIENUNGSANLEITUNG.md create mode 100644 README.md create mode 100644 Trump_in_China_kurz.txt create mode 100755 chatterbox_cli_v4.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c215f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ + +# Ausgabe-Dateien +*.wav +*.mp3 +*.ogg + +# Persönliche Daten (Stimmaufnahmen) +my_voice*.wav +voice_*.wav + +# Umgebung +.env +*.log +.venv/ +env/ + +# IDE +.vscode/ +.idea/ + +# Claude Code +.claude/ diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md new file mode 100644 index 0000000..2cff9c1 --- /dev/null +++ b/BEDIENUNGSANLEITUNG.md @@ -0,0 +1,200 @@ +# Bedienungsanleitung: Chatterbox TTS-Assistent + +Dieses Programm liest Texte laut vor — ähnlich wie ein Vorlesedienst. +Es wandelt geschriebenen Text in natürlich klingende Sprache um. + +--- + +## Was das Programm braucht + +- Einen Computer mit Linux +- Eine installierte Conda-Umgebung namens `chatterbox` +- Eine Grafikkarte (GPU) — macht das Programm deutlich schneller + +--- + +## Das Programm starten + +Öffne ein Terminal und gib folgende Befehle ein: + +```bash +conda activate chatterbox +cd ~/Python_Programs/chatterbox +``` + +--- + +## Einen Text vorlesen lassen + +### Text aus einer Datei vorlesen + +```bash +python chatterbox_cli_v4.py --lang de --input mein_text.txt +``` + +Ersetze `mein_text.txt` durch den Pfad zu deiner Textdatei. +Die Datei muss im Format **UTF-8** gespeichert sein (das ist der Standard +bei modernen Texteditoren). + +### Einen kurzen Text direkt eingeben + +```bash +python chatterbox_cli_v4.py --lang de --text "Guten Morgen! Wie geht es Ihnen heute?" +``` + +--- + +## Die eigene Stimme verwenden + +Wenn du eine Aufnahme deiner Stimme hast (eine WAV-Datei von ca. 10–30 Sekunden), +kann das Programm diese Stimme nachahmen: + +```bash +python chatterbox_cli_v4.py --lang de \ + --voice meine_stimme.wav \ + --input mein_text.txt +``` + +**Tipp:** Eine Aufnahme von 20 Sekunden reicht aus. Am besten in ruhiger Umgebung +und deutlich sprechen. + +--- + +## Sprache wählen + +Das Programm kann in vielen Sprachen vorlesen. Die Sprache wählt man mit `--lang`: + +| Befehl | Sprache | +|--------|---------| +| `--lang de` | Deutsch (Standard) | +| `--lang en` | Englisch | +| `--lang fr` | Französisch | +| `--lang es` | Spanisch | +| `--lang it` | Italienisch | + +Beispiel auf Englisch: + +```bash +python chatterbox_cli_v4.py --lang en --text "Good morning, how are you?" +``` + +--- + +## Sprechgeschwindigkeit anpassen + +Mit `--speed` kann man einstellen, wie schnell der Text gesprochen wird. + +- `1.0` = normale Geschwindigkeit (Standard) +- `0.85` = etwas langsamer — gut für ältere Hörer +- `0.75` = deutlich langsamer +- `1.2` = etwas schneller + +```bash +python chatterbox_cli_v4.py --lang de --speed 0.85 --input mein_text.txt +``` + +**Hinweis:** Die Stimmhöhe bleibt gleich — nur das Tempo ändert sich. + +--- + +## Audio als Datei speichern + +Wenn du die Audiodatei behalten möchtest: + +```bash +python chatterbox_cli_v4.py --lang de --save --input mein_text.txt +``` + +Die Datei wird automatisch als `mein_text.de.wav` gespeichert — im selben +Ordner wie die Eingabedatei. + +Oder mit eigenem Dateinamen: + +```bash +python chatterbox_cli_v4.py --lang de --output ausgabe.wav --input mein_text.txt +``` + +--- + +## Nur speichern, nicht abspielen + +```bash +python chatterbox_cli_v4.py --lang de --no-play --output ausgabe.wav --input mein_text.txt +``` + +--- + +## Aussprache von Eigennamen anpassen + +Manche Namen — vor allem aus anderen Sprachen — werden falsch ausgesprochen. +Du kannst das mit einer einfachen Textdatei im JSON-Format korrigieren. + +**Beispiel:** Datei `aussprache.json` anlegen: + +```json +{ + "Xi Jinping": "Schi Dschinping", + "Seoul": "Söul", + "Macron": "Makron" +} +``` + +Dann so aufrufen: + +```bash +python chatterbox_cli_v4.py --lang de \ + --pronunciation-dict aussprache.json \ + --input nachricht.txt +``` + +--- + +## Typischer Arbeitsablauf + +1. Text in einem Editor schreiben und als `.txt`-Datei speichern +2. Terminal öffnen, `conda activate chatterbox` +3. Programm aufrufen: + ```bash + python chatterbox_cli_v4.py --lang de --voice meine_stimme.wav --input text.txt + ``` +4. Das Programm beginnt sofort zu sprechen — Satz für Satz + +--- + +## Was das Programm automatisch macht + +- Abkürzungen buchstabieren: ARD wird zu „Ah Er De", YMCA zu „Ypsilon Em Tse Ah" +- Zusammengesetzte Wörter mit Abkürzung: „US-Präsident" wird zu „U Es Präsident" +- Uhrzeiten vorlesen: „14:58" wird zu „vierzehn Uhr achtundfünfzig" +- Jahreszahlen aussprechen: „2026" wird zu „zweitausendsechsundzwanzig" +- Trennzeilen wie „--- Ende ---" werden stillschweigend übersprungen + +--- + +## Wenn etwas nicht klappt + +**Kein Ton zu hören:** +```bash +# Ausgabegerät prüfen +python -c "import sounddevice; print(sounddevice.query_devices())" +``` +Dann `--audio-device pulse` oder das passende Gerät angeben. + +**„Modell nicht gefunden":** +Beim ersten Start wird das Modell heruntergeladen (~2 GB). +Sicherstellen, dass eine Internetverbindung besteht. + +**Programm ist sehr langsam:** +Ohne GPU dauert die Generierung länger als die Wiedergabe — ein Satz +kann 30–60 Sekunden brauchen. Mit GPU (CUDA) dauert es ca. 5–10 Sekunden. + +--- + +## Bekannte Grenzen + +- **Betonung einzelner Wörter** lässt sich nicht direkt steuern. + Eine Aufnahme der eigenen Stimme mit natürlicher Betonung kann helfen. +- **Manche Fremdwörter** (z. B. chinesische oder arabische Namen) klingen + nicht immer perfekt — mit der Aussprache-Datei lässt sich das korrigieren. +- Das Programm liest alles vor, was in der Datei steht — also auch + Überschriften und Metadaten wie „Schlagzeile:" oder „Stand:". diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb68309 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# chatterbox-tts-cli + +Ein kommandozeilenbasierter TTS-Assistent (Text-to-Speech) auf Basis von +[Chatterbox TTS](https://github.com/resemble-ai/chatterbox) (Resemble AI). +Optimiert für deutsche Sprache und den Einsatz als Audio-Vorlesehilfe, z. B. +für Senioren oder Accessibility-Anwendungen. + +## Features + +- **Satz-für-Satz-Streaming** — gibt den ersten Satz aus, während die nächsten + bereits generiert werden; minimale Latenz +- **Lückenlose Audiowiedergabe** — Callback-basierter OutputStream mit + vorgefertigten Blöcken; keine Unterbrechungen zwischen Sätzen +- **Geschwindigkeitsanpassung** — pitch-erhaltende Zeitstreckung via + pyrubberband (R3-Engine); konfigurierbar per `--speed` +- **Voice Cloning** — optionale WAV-Referenz für Akzent und Klang +- **Mehrsprachig** — Deutsch, Englisch und 20+ weitere Sprachen via + `ChatterboxMultilingualTTS` (erfordert Multilingual-Setup, s. u.) +- **Deutsche Textnormalisierung** + - Abkürzungen: ARD → "Ah Er De", YMCA → "Ypsilon Em Tse Ah" + - Komposita: US-Präsident → "U Es Präsident" + - Uhrzeiten: 14:58 → "vierzehn Uhr achtundfünfzig" + - Jahreszahlen: 2026 → "zweitausendsechsundzwanzig" + - Einheiten: 120 km/h → "120 Kilometer pro Stunde" +- **Konfigurierbares Aussprache-Wörterbuch** — Eigennamen und Fremdwörter + per JSON-Datei phonetisch überschreiben +- **Automatische Satz-Erkennung** — intelligentes Splitting inkl. + Ordinalzahlen, Paragraphen und Trennzeilen + +## Systemvoraussetzungen + +- Python 3.11+ +- CUDA-GPU empfohlen (RTX 3070 oder besser; CPU möglich aber langsam) +- Linux mit PipeWire oder PulseAudio (für `--audio-device pulse`) +- `rubberband-cli` (nur wenn `--speed` != 1.0 genutzt wird): + ```bash + sudo apt install rubberband-cli + ``` + +## Installation + +```bash +# 1. Conda-Umgebung erstellen (empfohlen) +conda create -n chatterbox python=3.11 +conda activate chatterbox + +# 2. PyTorch mit CUDA installieren (Beispiel für CUDA 12.4) +pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124 + +# 3. Chatterbox und weitere Abhängigkeiten +pip install chatterbox-tts sounddevice pyrubberband + +# 4. Skript herunterladen +# chatterbox_cli_v4.py in das Arbeitsverzeichnis legen +``` + +### Multilingual-Setup (für Deutsch und andere Nicht-Englisch-Sprachen) + +Das Standard-Paket `chatterbox-tts` enthält die Multilingual-Unterstützung +noch nicht vollständig. Notwendige Schritte: + +```bash +# Multilingual-Modell herunterladen (beim ersten Start automatisch) +# Modell-Auswahl: v3 (Standard, besser) oder v2 +# Wird in ~/.cache/huggingface/ gespeichert +``` + +Beim ersten Start mit `--lang de` werden die Modelle automatisch heruntergeladen +(ca. 2–3 GB). + +## Schnellstart + +```bash +# Deutschen Text vorlesen (aus Datei) +python chatterbox_cli_v4.py --lang de --input mein_text.txt + +# Mit eigener Stimme (Voice Cloning) +python chatterbox_cli_v4.py --lang de \ + --voice meine_stimme.wav \ + --input mein_text.txt + +# Etwas langsamer sprechen +python chatterbox_cli_v4.py --lang de --speed 0.85 --input mein_text.txt + +# Englisch +python chatterbox_cli_v4.py --lang en --text "Hello, how are you today?" +``` + +## Optionen + +| Option | Standard | Beschreibung | +|--------|----------|--------------| +| `--text TEXT` | — | Text direkt als Argument | +| `--input DATEI` | — | UTF-8-Textdatei als Eingabe | +| `--lang CODE` | `de` | Sprachcode (de, en, fr, es, …) | +| `--voice DATEI.wav` | — | Referenz-WAV für Voice Cloning | +| `--speed 0.85` | `1.0` | Geschwindigkeit (0.7–1.3); pitch bleibt gleich | +| `--audio-device` | `pulse` | Ausgabegerät (z. B. `pulse`, `default`) | +| `--t3-model` | `v3` | Multilingual-Modell: `v3` oder `v2` | +| `--acronym-mode` | `german` | Akronym-Modus: `german`, `space`, `period_space` | +| `--pronunciation-dict` | — | JSON-Datei mit Aussprache-Substitutionen | +| `--save` | nein | WAV-Datei speichern | +| `--output DATEI.wav` | — | Ausgabepfad (impliziert `--save`) | +| `--no-play` | — | Nur speichern, nicht abspielen | +| `--no-sentence-mode` | — | Text als Ganzes statt satzweise verarbeiten | +| `--debug-delay N` | `0` | Pause in Sekunden vor jedem Satz (zum Testen) | +| `--speed` | `1.0` | Sprechgeschwindigkeit | + +## Aussprache-Wörterbuch + +Für Eigennamen und Fremdwörter, die das Modell falsch ausspricht: + +```json +{ + "Xi Jinping": "Schi Dschinping", + "Putin": "Pjutin", + "Seoul": "Söul" +} +``` + +```bash +python chatterbox_cli_v4.py --lang de \ + --pronunciation-dict aussprache.json \ + --input nachricht.txt +``` + +## Bekannte Einschränkungen + +- **Wortbetonung** lässt sich nicht steuern — das Modell kennt kein SSML. + Abhilfe: Voice-Referenz mit gewünschter Betonung aufnehmen. +- **Chinesische/japanische Namen** werden phonetisch angenähert; das Modell + ist nicht für asiatische Phonetik optimiert. +- **Sehr lange Texte** werden satzweise verarbeitet; zwischen Absätzen können + kurze Pausen entstehen (Generierungszeit für den nächsten Satz). + +## Lizenz + +MIT — dieses Skript. Das Chatterbox-Modell unterliegt der MIT-Lizenz von +Resemble AI. Die Modellgewichte sind nicht-kommerziell (CC BY-NC 4.0). diff --git a/Trump_in_China_kurz.txt b/Trump_in_China_kurz.txt new file mode 100644 index 0000000..556cd4c --- /dev/null +++ b/Trump_in_China_kurz.txt @@ -0,0 +1,18 @@ + +Schlagzeile: Die neue Harmonie - mit Schlagseite + +Stand: 15. Mai 2026 - 14:58 + +​Unterschlagzeile: Ein Besuch in großer Eintracht, so hatte es US-Präsident Trump schon vor Abreise nach China versprochen, und so kam es auch. Doch der Eindruck bleibt, dass die Gegenleistung überschaubar ausfällt. + +Eine Analyse von Marie von Mallinckrodt, ARD Peking + +Die Blaskapelle der Volksbefreiungsarmee spielt eine Version von "YMCA", eines der Lieblingslieder von US-Präsident Donald Trump. Serviert wird Lobster in Tomatensuppe, Peking-Ente und vieles mehr. Gegessen wird mit goldenem Besteck. Die Stimmung beim pompösen Staatsbankett in der Halle des Volkes ist feierlich und freundlich. Zwei ehemalige Rivalen stoßen auf ihre Annäherung an. Es ist diese Aussage des chinesischen Staats- und Parteichefs Xi Jinping an diesem Abend, die den Wesenskern der Beziehung der beiden Staatsoberhäupter wohl am besten beschreibt: "Die große Wiederbelebung der chinesischen Nation und das Ziel 'Make America great again' können Hand in Hand gehen. Wir können uns gegenseitig zum Erfolg verhelfen." + +Zwischenzeile: Beide können zufrieden sein + +Sowohl Xi als auch Trump führen eine nationalistische und auf Eigeninteressen bedachte Außenpolitik. Mit dem Unterschied, dass Xi einen langfristigen, strategischen Plan hat, wie er die "Wiederbelebung der Nation" erreichen will und Trump ein eher impulsives Deal-Making betreibt. Xi möchte China gern bis 2049 zur dominierenden, globalen Supermacht machen. So betrachtet fällt die Bilanz des zweitägigen Treffens ganz offenbar für beide nicht schlecht aus, wenn man die beiden wichtigsten Krisenfelder aus ihrer jeweiligen Perspektive betrachtet: Handel und Geopolitik. + + +--- Ende --- + diff --git a/chatterbox_cli_v4.py b/chatterbox_cli_v4.py new file mode 100755 index 0000000..14c2a64 --- /dev/null +++ b/chatterbox_cli_v4.py @@ -0,0 +1,1033 @@ +#!/usr/bin/env python3 + +import argparse +import importlib.util +import queue +import re +import sys +import threading +import time +from pathlib import Path +from typing import List, Optional, Tuple + +import torch +import torchaudio as ta + +# SDPA does not support output_attentions=True (required by AlignmentStreamAnalyzer hook); +# fall back to eager attention so attention weights are returned as tensors, not None. +import chatterbox.models.t3.llama_configs as _llama_cfg +_llama_cfg.LLAMA_520M_CONFIG_DICT["attn_implementation"] = "eager" + +from chatterbox.tts import ChatterboxTTS + +try: + from chatterbox.mtl_tts import ChatterboxMultilingualTTS + HAS_MULTILINGUAL = True +except Exception: + ChatterboxMultilingualTTS = None + HAS_MULTILINGUAL = False + + +SUPPORTED_LANGS = { + "ar", "da", "de", "el", "en", "es", "fi", "fr", "he", "hi", "it", + "ja", "ko", "ms", "nl", "no", "pl", "pt", "ru", "sv", "sw", "tr", "zh" +} + +SENTENCE_END_RE = re.compile( + r'.+?(?:' + r'\.\.\.|…|' + r'[!?¡¿]+|' + r'[!?。]+|' + r'‽|' + r'(? str: + for phrase, replacement in sorted(pron_dict.items(), key=lambda x: len(x[0]), reverse=True): + text = text.replace(phrase, replacement) + return text + + +def clean_raw_text(text: str) -> str: + """Unsichtbare Steuerzeichen entfernen, die Splitting oder TTS stoeren.""" + for ch in ('​', '‌', '‍', ''): + text = text.replace(ch, '') + return text + + +def has_module(name: str) -> bool: + return importlib.util.find_spec(name) is not None + + +def get_device(explicit_device: Optional[str] = None) -> str: + if explicit_device: + if explicit_device.startswith("cuda") and not torch.cuda.is_available(): + raise RuntimeError("CUDA angefordert, aber keine CUDA-GPU verfügbar.") + if explicit_device.startswith("cuda"): + try: + idx = int(explicit_device.split(":")[1]) if ":" in explicit_device else 0 + except (IndexError, ValueError): + idx = 0 + torch.cuda.set_device(idx) + return explicit_device + + if torch.cuda.is_available(): + torch.cuda.set_device(0) + return "cuda:0" + + return "cpu" + + +def number_to_words_de(n: int) -> str: + ones = { + 0: "null", 1: "eins", 2: "zwei", 3: "drei", 4: "vier", 5: "fünf", + 6: "sechs", 7: "sieben", 8: "acht", 9: "neun", 10: "zehn", + 11: "elf", 12: "zwölf", 13: "dreizehn", 14: "vierzehn", + 15: "fünfzehn", 16: "sechzehn", 17: "siebzehn", 18: "achtzehn", + 19: "neunzehn" + } + tens = { + 20: "zwanzig", 30: "dreißig", 40: "vierzig", 50: "fünfzig", + 60: "sechzig", 70: "siebzig", 80: "achtzig", 90: "neunzig" + } + + if n < 20: + return ones[n] + + if n < 100: + t = (n // 10) * 10 + o = n % 10 + if o == 0: + return tens[t] + one_prefix = "ein" if o == 1 else ones[o] + return f"{one_prefix}und{tens[t]}" + + if n < 1000: + h = n // 100 + r = n % 100 + prefix = "einhundert" if h == 1 else f"{ones[h]}hundert" + return prefix if r == 0 else f"{prefix}{number_to_words_de(r)}" + + if n < 1000000: + th = n // 1000 + r = n % 1000 + prefix = "eintausend" if th == 1 else f"{number_to_words_de(th)}tausend" + return prefix if r == 0 else f"{prefix}{number_to_words_de(r)}" + + return str(n) + + +def number_to_words_en(n: int) -> str: + ones = { + 0: "zero", 1: "one", 2: "two", 3: "three", 4: "four", 5: "five", + 6: "six", 7: "seven", 8: "eight", 9: "nine", 10: "ten", + 11: "eleven", 12: "twelve", 13: "thirteen", 14: "fourteen", + 15: "fifteen", 16: "sixteen", 17: "seventeen", 18: "eighteen", + 19: "nineteen" + } + tens = { + 20: "twenty", 30: "thirty", 40: "forty", 50: "fifty", + 60: "sixty", 70: "seventy", 80: "eighty", 90: "ninety" + } + + if n < 20: + return ones[n] + + if n < 100: + t = (n // 10) * 10 + o = n % 10 + return tens[t] if o == 0 else f"{tens[t]}-{ones[o]}" + + if n < 1000: + h = n // 100 + r = n % 100 + prefix = f"{ones[h]} hundred" + return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}" + + if n < 1000000: + th = n // 1000 + r = n % 1000 + prefix = f"{number_to_words_en(th)} thousand" + return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}" + + return str(n) + + +def year_to_words_de(year: int) -> str: + if year < 1000 or year > 9999: + return str(year) + + if year == 2000: + return "zweitausend" + + if 2001 <= year <= 2099: + return f"zweitausend{number_to_words_de(year - 2000)}" + + return number_to_words_de(year) + + +def year_to_words_en(year: int) -> str: + if year < 1000 or year > 9999: + return str(year) + + if 2000 <= year <= 2009: + if year == 2000: + return "two thousand" + return f"two thousand {number_to_words_en(year - 2000)}" + + if 2010 <= year <= 2099: + last_two = year % 100 + return f"twenty {number_to_words_en(last_two)}" + + first_two = year // 100 + last_two = year % 100 + if last_two == 0: + return f"{number_to_words_en(first_two)} hundred" + return f"{number_to_words_en(first_two)} {number_to_words_en(last_two)}" + + +def spell_out_acronym(token: str, mode: str = "period_space") -> str: + chars = list(token) + + if mode == "german": + return " ".join(GERMAN_LETTER_NAMES.get(c, c) for c in chars) + + if mode == "space": + return " ".join(chars) + + if mode == "period": + return ".".join(chars) + "." + + if mode == "comma": + return ", ".join(chars) + + if mode == "period_space": + return ". ".join(chars) + "." + + raise ValueError(f"Unbekannter mode: {mode}") + + +def normalize_units(text: str, lang: str) -> str: + if lang != "de": + return text + + for unit, expanded in sorted(UNIT_REPLACEMENTS.items(), key=lambda x: len(x[0]), reverse=True): + text = re.sub(rf'(?<=\d)\s*{re.escape(unit)}\b', f" {expanded}", text) + + return text + + +def normalize_times(text: str, lang: str) -> str: + def repl(match: re.Match) -> str: + hh = int(match.group(1)) + mm = int(match.group(3)) + + if lang == "de": + if mm == 0: + return f"{number_to_words_de(hh)} Uhr" + return f"{number_to_words_de(hh)} Uhr {number_to_words_de(mm)}" + + if lang == "en": + if hh == 0 and mm == 0: + return "twelve midnight" + if hh == 12 and mm == 0: + return "twelve noon" + + hour12 = hh % 12 + if hour12 == 0: + hour12 = 12 + suffix = "a m" if hh < 12 else "p m" + + if mm == 0: + return f"{number_to_words_en(hour12)} {suffix}" + if mm < 10: + return f"{number_to_words_en(hour12)} oh {number_to_words_en(mm)} {suffix}" + return f"{number_to_words_en(hour12)} {number_to_words_en(mm)} {suffix}" + + return match.group(0) + + return TIME_RE.sub(repl, text) + + +def normalize_years(text: str, lang: str) -> str: + def repl(match: re.Match) -> str: + year = int(match.group(1)) + + if lang == "de": + return year_to_words_de(year) + + if lang == "en": + return year_to_words_en(year) + + return match.group(0) + + return YEAR_RE.sub(repl, text) + + +def preprocess_tts_text( + text: str, + lang: str, + spell_uppercase_acronyms: bool = True, + acronym_mode: Optional[str] = None, # None = auto: 'german' bei de, sonst 'period_space' + normalize_time_values: bool = True, + normalize_year_values: bool = True, + normalize_units_values: bool = True, + pronunciation_dict: Optional[dict] = None, +) -> str: + if acronym_mode is None: + acronym_mode = "german" if lang == "de" else "period_space" + + # 1. Aussprache-Wörterbuch zuerst (vor Akronym-Expansion, damit Eigennamen greifen) + if lang == "de": + text = apply_pronunciation_dict(text, DEFAULT_PRONUNCIATION_DE) + if pronunciation_dict: + text = apply_pronunciation_dict(text, pronunciation_dict) + + if normalize_units_values: + text = normalize_units(text, lang) + + if normalize_time_values: + text = normalize_times(text, lang) + + if normalize_year_values: + text = normalize_years(text, lang) + + if spell_uppercase_acronyms: + def repl_compound(match: re.Match) -> str: + acr = match.group(1) + if acr in NON_SPELLED_ACRONYMS: + return acr + " " + return spell_out_acronym(acr, mode=acronym_mode) + " " + + def repl(match: re.Match) -> str: + token = match.group(0) + if token in NON_SPELLED_ACRONYMS: + return token + return spell_out_acronym(token, mode=acronym_mode) + + # Compound zuerst: "US-Präsident" → "U Es Präsident" (Bindestrich weg) + text = ACRONYM_COMPOUND_RE.sub(repl_compound, text) + # Dann verbleibende Akronyme buchstabieren + text = UPPER_ACRONYM_RE.sub(repl, text) + + text = re.sub(r'\s+', ' ', text).strip() + return text + + +def split_long_text(text: str, max_len: int = 400) -> List[str]: + chunks = [] + current = "" + + for part in text.split("\n\n"): + part = part.strip() + if not part: + continue + + sentences = SENTENCE_END_RE.findall(part) + + consumed = "".join(sentences).strip() + rest = part[len(consumed):].strip() + if rest: + sentences.append(rest) + + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + if len(sentence) > max_len: + if current: + chunks.append(current.strip()) + current = "" + + chunks.extend(force_split_sentence(sentence, max_len)) + continue + + if current and len(current) + 1 + len(sentence) > max_len: + chunks.append(current.strip()) + current = sentence + else: + current = f"{current} {sentence}".strip() if current else sentence + + if current: + chunks.append(current.strip()) + current = "" + + return chunks + + +def split_for_conversation(text: str, first_chunk_len: int = 120, max_len: int = 400) -> List[str]: + base_chunks = split_long_text(text, max_len=max_len) + if not base_chunks: + return [] + + first = base_chunks[0] + if len(first) <= first_chunk_len: + return base_chunks + + early = force_split_sentence(first, first_chunk_len) + return early + base_chunks[1:] + + +def force_split_sentence(text: str, max_len: int) -> List[str]: + text = re.sub(r"\s+", " ", text).strip() + if len(text) <= max_len: + return [text] + + parts = [] + remaining = text + + while len(remaining) > max_len: + split_pos = remaining.rfind(" ", 0, max_len + 1) + if split_pos <= 0: + split_pos = max_len + parts.append(remaining[:split_pos].strip()) + remaining = remaining[split_pos:].strip() + + if remaining: + parts.append(remaining) + + return parts + + +def split_into_sentences(text: str, max_len: int = 200) -> List[str]: + result = [] + for part in text.split("\n\n"): + part = part.strip() + if not part: + continue + if SEPARATOR_LINE_RE.match(part): + continue + sentences = SENTENCE_END_RE.findall(part) + consumed = "".join(sentences).strip() + rest = part[len(consumed):].strip() + if rest: + sentences.append(rest) + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + if len(sentence) > max_len: + result.extend(force_split_sentence(sentence, max_len)) + else: + result.append(sentence) + return result + + +def read_input_text(text_arg: Optional[str], input_path: Optional[str]) -> str: + if text_arg and input_path: + raise ValueError("Bitte entweder --text oder --input angeben, nicht beides.") + if not text_arg and not input_path: + raise ValueError("Bitte --text oder --input angeben.") + + if text_arg: + return text_arg.strip() + + path = Path(input_path) + if not path.exists(): + raise FileNotFoundError(f"Input-Datei nicht gefunden: {path}") + + return path.read_text(encoding="utf-8").strip() + + +def default_output_path(input_path: Optional[str], lang: str) -> Path: + if input_path: + src = Path(input_path) + return src.with_suffix(f".{lang}.wav") + return Path(f"tts_output.{lang}.wav") + + +def load_model(lang: str, device: str, t3_model: Optional[str] = None): + if lang == "en": + model = ChatterboxTTS.from_pretrained(device=device) + return model, "mono", model.sr + + if not HAS_MULTILINGUAL: + raise RuntimeError( + "Multilingual-Modell nicht verfügbar. Installiere ein Chatterbox-Paket mit chatterbox.mtl_tts." + ) + + model = ChatterboxMultilingualTTS.from_pretrained(device=device, t3_model=t3_model) + return model, "multi", model.sr + + +def generate_chunk(model, model_kind: str, text: str, lang: str, voice_path: Optional[str]): + kwargs = {} + if voice_path: + kwargs["audio_prompt_path"] = voice_path + + if model_kind == "mono": + return model.generate(text, **kwargs) + + return model.generate(text, language_id=lang, **kwargs) + + +def generate_stream_chunk( + model, + model_kind: str, + text: str, + lang: str, + voice_path: Optional[str], + stream_chunk_size: int, +): + kwargs = { + "chunk_size": stream_chunk_size, + "print_metrics": False, + } + if voice_path: + kwargs["audio_prompt_path"] = voice_path + + if model_kind == "mono": + return model.generate_stream(text, **kwargs) + + return model.generate_stream(text, language_id=lang, **kwargs) + + +class PlaybackWorker: + PLAYBACK_RATE = 48000 # PipeWire/PulseAudio standard + CALLBACK_BLOCK = 2048 # ~43 ms pro Callback-Block bei 48 kHz + + def __init__(self, sample_rate: int, device: Optional[str] = "pulse", speed: float = 1.0): + self.sample_rate = sample_rate + self.device = device + self.speed = speed + # Eingang: Torch-Tensoren vom TTS-Modell + self.audio_queue: "queue.Queue[Optional[torch.Tensor]]" = queue.Queue() + # Intern: fertig vorbereitete numpy-Blöcke für den Callback + self._block_queue: "queue.Queue" = queue.Queue(maxsize=500) + self._blocks_produced = 0 + self._blocks_consumed = 0 + self.thread = None + self.error = None + + def start(self): + if not has_module("sounddevice"): + raise RuntimeError( + "Für Live-Wiedergabe ist das Modul 'sounddevice' nötig. Installiere z. B. 'pip install sounddevice'." + ) + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + + def _callback(self, outdata, frames, time_info, status): + # Läuft im Audio-Thread: so schnell wie möglich, kein Lock nötig. + try: + data = self._block_queue.get_nowait() + outdata[:, 0] = data + self._blocks_consumed += 1 + except queue.Empty: + outdata[:] = 0.0 # Stille statt Underrun-Klick + + def _produce(self): + """Wandelt Torch-Tensoren in CALLBACK_BLOCK-große numpy-Arrays um.""" + import numpy as np + + remainder = np.zeros(0, dtype="float32") + + while True: + item = self.audio_queue.get() + if item is None: + break + + chunk = item.detach().cpu() + if chunk.ndim == 2: + chunk = chunk.squeeze(0) + + if self.speed != 1.0: + import pyrubberband as pyrb + # R3-Engine (--fine): deutlich weniger Phasiness als R2, besser für Sprache. + # rate < 1.0 = langsamer, rate > 1.0 = schneller; Pitch bleibt gleich. + stretched = pyrb.time_stretch( + chunk.numpy().astype("float64"), self.sample_rate, self.speed, + rbargs={"--fine": ""}, + ) + chunk = torch.from_numpy(stretched.astype("float32")) + + chunk = ta.functional.resample(chunk, self.sample_rate, self.PLAYBACK_RATE) + + samples = np.concatenate([remainder, chunk.numpy().astype("float32")]) + + i = 0 + while i + self.CALLBACK_BLOCK <= len(samples): + self._block_queue.put(samples[i : i + self.CALLBACK_BLOCK]) + self._blocks_produced += 1 + i += self.CALLBACK_BLOCK + remainder = samples[i:] + + # Restliche Samples (< CALLBACK_BLOCK) mit Stille auffüllen + if len(remainder) > 0: + block = np.zeros(self.CALLBACK_BLOCK, dtype="float32") + block[: len(remainder)] = remainder + self._block_queue.put(block) + self._blocks_produced += 1 + + def _run(self): + try: + import sounddevice as sd + + producer = threading.Thread(target=self._produce, daemon=True) + producer.start() + + with sd.OutputStream( + samplerate=self.PLAYBACK_RATE, + channels=1, + dtype="float32", + device=self.device, + blocksize=self.CALLBACK_BLOCK, + callback=self._callback, + ): + producer.join() # alle Tensoren sind zu Blöcken konvertiert + + # Warten bis der Callback alle Blöcke abgespielt hat + while self._blocks_consumed < self._blocks_produced: + time.sleep(0.02) + + # Letzten Block aus Hardware-Buffer ausspielen lassen + time.sleep(self.CALLBACK_BLOCK / self.PLAYBACK_RATE + 0.1) + + except Exception as e: + self.error = e + + def put(self, chunk: torch.Tensor): + self.audio_queue.put(chunk) + + def stop(self): + self.audio_queue.put(None) + if self.thread: + self.thread.join() + if self.error: + raise RuntimeError(f"Fehler bei Live-Wiedergabe: {self.error}") + + +def synthesize_non_streaming( + text: str, + lang: str, + output_path: Optional[Path], + max_len: int, + first_chunk_len: int, + voice_path: Optional[str], + device: str, + show_progress: bool = True, + spell_uppercase_acronyms: bool = True, + acronym_mode: Optional[str] = None, + normalize_time_values: bool = True, + normalize_year_values: bool = True, + normalize_units_values: bool = True, + conversation_mode: bool = True, + play_audio: bool = False, + save_wav: bool = True, + audio_device: Optional[str] = "pulse", + sentence_mode: bool = True, + speed: float = 1.0, + debug_delay: float = 0.0, + t3_model: Optional[str] = None, + pronunciation_dict: Optional[dict] = None, +) -> Optional[Path]: + if lang not in SUPPORTED_LANGS: + raise ValueError( + f"Nicht unterstützte Sprache '{lang}'. Unterstützt: {', '.join(sorted(SUPPORTED_LANGS))}" + ) + + if voice_path and not Path(voice_path).exists(): + raise FileNotFoundError(f"Voice-Referenz nicht gefunden: {voice_path}") + + # Erst unsichtbare Zeichen entfernen, dann Sätze splitten (Paragraphen-Struktur erhalten), + # danach erst Akronym-Expansion — sonst erzeugen "A. R. D."-Punkte falsche Satzgrenzen. + text = clean_raw_text(text) + + model, model_kind, sr = load_model(lang, device, t3_model=t3_model) + + if sentence_mode: + raw_chunks = split_into_sentences(text, max_len=max_len) + elif conversation_mode: + raw_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len) + else: + raw_chunks = split_long_text(text, max_len=max_len) + + preprocess_kw = dict( + lang=lang, + spell_uppercase_acronyms=spell_uppercase_acronyms, + acronym_mode=acronym_mode, + normalize_time_values=normalize_time_values, + normalize_year_values=normalize_year_values, + normalize_units_values=normalize_units_values, + pronunciation_dict=pronunciation_dict, + ) + chunks = [preprocess_tts_text(c, **preprocess_kw) for c in raw_chunks] + chunks = [c for c in chunks if c.strip()] + + if not chunks: + raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.") + + if show_progress: + print(f"Sprache: {lang}") + print(f"Gerät: {device}") + print(f"Modell: {'ChatterboxTTS (monolingual)' if model_kind == 'mono' else 'ChatterboxMultilingualTTS'}") + print(f"Sätze: {len(chunks)}") + print(f"Modus: {'Satz-für-Satz' if sentence_mode else 'non-streaming'} + Playback") + print(f"Live-Wiedergabe: {'ja' if play_audio else 'nein'}") + print(f"WAV speichern: {'ja' if save_wav and output_path else 'nein'}") + if output_path and save_wav: + print(f"Ausgabe: {output_path}") + + if play_audio: + playback = PlaybackWorker(sample_rate=sr, device=audio_device, speed=speed) + playback.start() + else: + playback = None + + wavs = [] + try: + for i, chunk in enumerate(chunks, start=1): + if debug_delay > 0: + if show_progress: + print(f"[{i}/{len(chunks)}] Warte {debug_delay:.0f}s (debug_delay) ...") + time.sleep(debug_delay) + if show_progress: + print(f"[{i}/{len(chunks)}] Generiere ({len(chunk)} Zeichen) ...") + wav = generate_chunk(model, model_kind, chunk, lang, voice_path) + wavs.append(wav) + if playback is not None: + playback.put(wav) + finally: + if playback is not None: + playback.stop() + + if not wavs: + return None + + final_wav = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1) + + if save_wav and output_path: + output_path.parent.mkdir(parents=True, exist_ok=True) + ta.save(str(output_path), final_wav, sr) + return output_path + + return None + + +def synthesize_streaming( + text: str, + lang: str, + output_path: Optional[Path], + max_len: int, + first_chunk_len: int, + voice_path: Optional[str], + device: str, + show_progress: bool = True, + spell_uppercase_acronyms: bool = True, + acronym_mode: str = "period_space", + normalize_time_values: bool = True, + normalize_year_values: bool = True, + normalize_units_values: bool = True, + conversation_mode: bool = True, + play_audio: bool = True, + save_wav: bool = True, + stream_chunk_size: int = 25, + audio_device: Optional[str] = None, +) -> Optional[Path]: + if lang not in SUPPORTED_LANGS: + raise ValueError( + f"Nicht unterstützte Sprache '{lang}'. Unterstützt: {', '.join(sorted(SUPPORTED_LANGS))}" + ) + + if voice_path and not Path(voice_path).exists(): + raise FileNotFoundError(f"Voice-Referenz nicht gefunden: {voice_path}") + + text = preprocess_tts_text( + text=text, + lang=lang, + spell_uppercase_acronyms=spell_uppercase_acronyms, + acronym_mode=acronym_mode, + normalize_time_values=normalize_time_values, + normalize_year_values=normalize_year_values, + normalize_units_values=normalize_units_values, + ) + + model, model_kind, sr = load_model(lang, device) + + if not hasattr(model, "generate_stream"): + raise RuntimeError( + "Dieses Chatterbox-Paket bietet kein generate_stream(). " + "Installiere z. B. 'chatterbox-streaming'." + ) + + if conversation_mode: + text_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len) + else: + text_chunks = split_long_text(text, max_len=max_len) + + if not text_chunks: + raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.") + + if play_audio: + playback = PlaybackWorker(sample_rate=sr, device=audio_device) + playback.start() + else: + playback = None + + all_audio_chunks: List[torch.Tensor] = [] + t0 = time.perf_counter() + first_audio_started = False + + if show_progress: + print(f"Sprache: {lang}") + print(f"Gerät: {device}") + print(f"Modell: {'ChatterboxTTS (monolingual)' if model_kind == 'mono' else 'ChatterboxMultilingualTTS'}") + print(f"Text-Chunks: {len(text_chunks)}") + print(f"Modus: streaming") + print(f"Gesprächsmodus: {'ja' if conversation_mode else 'nein'}") + print(f"Live-Wiedergabe: {'ja' if play_audio else 'nein'}") + print(f"WAV speichern: {'ja' if save_wav and output_path else 'nein'}") + print(f"Streaming chunk_size: {stream_chunk_size}") + if output_path: + print(f"Ausgabe: {output_path}") + + try: + for text_idx, text_chunk in enumerate(text_chunks, start=1): + if show_progress: + print(f"[Text {text_idx}/{len(text_chunks)}] Starte Streaming für {len(text_chunk)} Zeichen ...") + + stream_iter = generate_stream_chunk( + model=model, + model_kind=model_kind, + text=text_chunk, + lang=lang, + voice_path=voice_path, + stream_chunk_size=stream_chunk_size, + ) + + for audio_idx, item in enumerate(stream_iter, start=1): + if isinstance(item, tuple) and len(item) == 2: + audio_chunk, metrics = item + else: + audio_chunk, metrics = item, None + + all_audio_chunks.append(audio_chunk) + + if playback is not None: + playback.put(audio_chunk) + if not first_audio_started: + first_audio_started = True + if show_progress: + dt = time.perf_counter() - t0 + print(f"Audio-Wiedergabe gestartet nach {dt:.3f}s") + + if show_progress: + msg = f" -> Audio-Chunk {audio_idx}" + if metrics is not None: + latency = getattr(metrics, "latency_to_first_chunk", None) + rtf = getattr(metrics, "rtf", None) + chunk_count = getattr(metrics, "chunk_count", None) + if chunk_count is not None: + msg += f", model_chunk={chunk_count}" + if latency: + msg += f", first_latency={latency:.3f}s" + if rtf: + msg += f", rtf={rtf:.3f}" + print(msg) + + finally: + if playback is not None: + playback.stop() + + final_output = None + if save_wav and output_path: + final_audio = all_audio_chunks[0] if len(all_audio_chunks) == 1 else torch.cat(all_audio_chunks, dim=-1) + output_path.parent.mkdir(parents=True, exist_ok=True) + ta.save(str(output_path), final_audio, sr) + final_output = output_path + + return final_output + + +def build_argparser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Low-Latency Chatterbox TTS CLI mit deutscher Text-Normalisierung und optionalem Streaming." + ) + p.add_argument("--text", type=str, help="Direkter Eingabetext.") + p.add_argument("--input", type=str, help="Pfad zu UTF-8-Textdatei.") + p.add_argument("--lang", type=str, default="de", help="Sprachcode, default: de.") + p.add_argument("--len", dest="max_len", type=int, default=400, help="Maximale Chunk-Länge, default: 400.") + p.add_argument("--first-chunk-len", type=int, default=80, help="Kleinere Zielgröße für den ersten Chunk im Gesprächsmodus. Default: 80.") + p.add_argument("--output", type=str, help="Ausgabedatei .wav") + p.add_argument("--voice", type=str, help="Optionale Referenz-WAV für Voice-Cloning.") + p.add_argument("--device", type=str, default=None, help="z. B. cuda:0 oder cpu.") + p.add_argument("--no-progress", action="store_true", help="Weniger Konsolen-Output.") + p.add_argument("--no-spell-acronyms", action="store_true", help="Großgeschriebene Akronyme nicht buchstabieren.") + p.add_argument( + "--acronym-mode", + type=str, + default=None, # None = automatisch: 'german' bei de, 'period_space' sonst + choices=["space", "period", "comma", "period_space", "german"], + help="Ausgabeformat für buchstabierte Akronyme. Default: 'german' bei --lang de, sonst 'period_space'." + ) + p.add_argument("--pronunciation-dict", type=str, default=None, help="Pfad zu einer JSON-Datei mit Aussprache-Substitutionen (Eigenname → Lautschrift).") + p.add_argument("--no-normalize-times", action="store_true", help="Uhrzeiten nicht in sprechbaren Text umwandeln.") + p.add_argument("--no-normalize-years", action="store_true", help="Jahreszahlen nicht in sprechbaren Text umwandeln.") + p.add_argument("--no-normalize-units", action="store_true", help="Einheiten nicht in sprechbaren Text umwandeln.") + p.add_argument("--stream", action="store_true", help="Streaming-TTS-Modus (experimentell, kann abgehackt klingen).") + p.add_argument("--no-play", action="store_true", help="Nicht live abspielen.") + p.add_argument("--audio-device", type=str, default="pulse", help="Sounddevice-Ausgabegerät, z. B. 'pulse' oder 'M2: USB Audio'. Standard: pulse.") + p.add_argument("--save", action="store_true", help="WAV-Datei speichern (Standard: nein).") + p.add_argument("--stream-chunk-size", type=int, default=12, help="Streaming chunk_size (nur mit --stream). Default: 12.") + p.add_argument("--no-sentence-mode", action="store_true", help="Sätze zu größeren Chunks gruppieren statt einzeln ausgeben.") + p.add_argument("--speed", type=float, default=1.0, help="Wiedergabegeschwindigkeit: 0.8 = 20%% langsamer, 1.2 = 20%% schneller. Default: 1.0.") + p.add_argument("--debug-delay", type=float, default=0.0, help="Sekunden Pause vor jedem Satz (simuliert langsame KI). Nur zum Testen.") + p.add_argument("--t3-model", type=str, default="v3", help="Multilingual T3-Modell: 'v3' (default), 'v2' oder Dateiname.") + p.add_argument("--no-conversation-mode", action="store_true", help="Ersten Chunk nicht künstlich kleiner machen (nur ohne --no-sentence-mode).") + return p + + +def main() -> int: + parser = build_argparser() + args = parser.parse_args() + + try: + text = read_input_text(args.text, args.input) + device = get_device(args.device) + output_path = Path(args.output) if args.output else default_output_path(args.input, args.lang) + + save_wav = args.save or bool(args.output) + + # Acronym-Mode-Default: 'german' bei Deutsch, 'period_space' sonst + acronym_mode = args.acronym_mode or ("german" if args.lang == "de" else "period_space") + + # Optionales Aussprache-Wörterbuch laden + pronunciation_dict: Optional[dict] = None + if args.pronunciation_dict: + import json + pron_path = Path(args.pronunciation_dict) + if not pron_path.exists(): + raise FileNotFoundError(f"Aussprache-Dict nicht gefunden: {pron_path}") + pronunciation_dict = json.loads(pron_path.read_text(encoding="utf-8")) + + if args.stream: + out = synthesize_streaming( + text=text, + lang=args.lang, + output_path=output_path if save_wav else None, + max_len=args.max_len, + first_chunk_len=args.first_chunk_len, + voice_path=args.voice, + device=device, + show_progress=not args.no_progress, + spell_uppercase_acronyms=not args.no_spell_acronyms, + acronym_mode=acronym_mode, + normalize_time_values=not args.no_normalize_times, + normalize_year_values=not args.no_normalize_years, + normalize_units_values=not args.no_normalize_units, + conversation_mode=not args.no_conversation_mode, + play_audio=not args.no_play, + save_wav=save_wav, + stream_chunk_size=args.stream_chunk_size, + audio_device=args.audio_device, + ) + else: + out = synthesize_non_streaming( + text=text, + lang=args.lang, + output_path=output_path if save_wav else None, + max_len=args.max_len, + first_chunk_len=args.first_chunk_len, + voice_path=args.voice, + device=device, + show_progress=not args.no_progress, + spell_uppercase_acronyms=not args.no_spell_acronyms, + acronym_mode=acronym_mode, + normalize_time_values=not args.no_normalize_times, + normalize_year_values=not args.no_normalize_years, + normalize_units_values=not args.no_normalize_units, + conversation_mode=not args.no_conversation_mode, + play_audio=not args.no_play, + save_wav=save_wav, + audio_device=args.audio_device, + sentence_mode=not args.no_sentence_mode, + speed=args.speed, + debug_delay=args.debug_delay, + t3_model=args.t3_model, + pronunciation_dict=pronunciation_dict, + ) + + if out is not None: + print(f"Fertig: {out}") + else: + print("Fertig.") + return 0 + + except Exception as e: + print(f"Fehler: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6408a4d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,17 @@ +# Chatterbox TTS CLI — Abhängigkeiten +# Getestet mit Python 3.11, CUDA 12.x, Ubuntu 22.04/24.04 + +# TTS-Kern +chatterbox-tts>=0.1.7 + +# PyTorch (passende CUDA-Version separat installieren, z. B. via pytorch.org) +torch>=2.6.0 +torchaudio>=2.6.0 + +# Audio-Ausgabe (Linux/PipeWire/PulseAudio) +sounddevice>=0.4.0 + +# Pitch-erhaltende Zeitstreckung (Geschwindigkeitsanpassung) +pyrubberband>=0.4.0 +# rubberband-cli muss zusätzlich als Systempakete installiert sein: +# sudo apt install rubberband-cli From bcf6374c2961c7833a285c3a06b6d2f857562391 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 16 May 2026 09:46:43 +0200 Subject: [PATCH 2/4] Erweiterung: Stop-Mechanismus, REST-Service und MCP-Adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chatterbox_cli_v4.py: kooperativer Stop-Mechanismus via threading.Event (STOP_REQUESTED, request_stop, clear_stop); PlaybackWorker, synthesize_non_streaming und synthesize_streaming prüfen das Event vor jedem Chunk; --stop CLI-Flag - tts_service.py: FastAPI-Service mit Modell-Caching, Job-Queue und Worker-Thread; Endpunkte: POST /speak, POST /stop, GET /health, GET /status, GET /voices - mcp_adapter.py: MCP-Adapter (stdio/streamable-http) über tts_service; Tools: speak, stop, get_status, list_voices - requirements.txt: fastapi, uvicorn, httpx, mcp ergänzt - CLAUDE.md: Architektur und Startbefehle dokumentiert - .gitignore: Ideen/-Verzeichnis ausgeschlossen Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + CLAUDE.md | 86 +++++++++++++ chatterbox_cli_v4.py | 50 +++++++- mcp_adapter.py | 134 ++++++++++++++++++++ requirements.txt | 10 ++ tts_service.py | 282 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 CLAUDE.md create mode 100644 mcp_adapter.py create mode 100644 tts_service.py diff --git a/.gitignore b/.gitignore index 0c215f6..38953f4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ env/ # Claude Code .claude/ + +# Ideen +Ideen/ + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..32e2c30 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Running the CLI + +```bash +conda activate chatterbox + +# Deutschen Text aus Datei vorlesen +python chatterbox_cli_v4.py --lang de --input text.txt + +# Mit Voice Cloning +python chatterbox_cli_v4.py --lang de --voice my_voice.wav --input text.txt + +# Text direkt übergeben (Englisch) +python chatterbox_cli_v4.py --lang en --text "Hello world" + +# Nur speichern, kein Playback +python chatterbox_cli_v4.py --lang de --no-play --output ausgabe.wav --input text.txt + +# Geschwindigkeit anpassen (pitch-erhaltend, erfordert rubberband-cli) +python chatterbox_cli_v4.py --lang de --speed 0.85 --input text.txt + +# Streaming-Modus (experimentell, niedrigere Latenz, kann abgehackt klingen) +python chatterbox_cli_v4.py --lang de --stream --input text.txt + +# Aussprache-Wörterbuch (JSON: {"Eigenname": "Lautschrift"}) +python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --input text.txt +``` + +No build step, no test suite, no linter configuration — this is a single-file script. + +## Architecture + +Everything lives in `chatterbox_cli_v4.py`. The processing pipeline is: + +**Text input → normalization → chunking → TTS generation → audio output** + +### Text normalization (`preprocess_tts_text`) +Applied per chunk before synthesis. Order matters: +1. Pronunciation dict substitutions (before acronym expansion, so proper names are caught first) +2. Unit normalization (120 km/h → "120 Kilometer pro Stunde") +3. Time normalization (14:58 → "vierzehn Uhr achtundfünfzig") +4. Year normalization (2026 → "zweitausendsechsundzwanzig") +5. Acronym spelling (ARD → "Ah Er De"; skips entries in `NON_SPELLED_ACRONYMS`) + +`DEFAULT_PRONUNCIATION_DE` contains built-in German phonetic approximations (e.g. Xi → "Schi"). + +### Text chunking +Three modes (chosen by CLI flags): +- **sentence_mode** (default): `split_into_sentences()` — one sentence per TTS call, lowest latency to first audio +- **conversation_mode**: `split_for_conversation()` — first chunk is small (`--first-chunk-len`, default 80 chars), rest up to `--len` (400) +- **plain**: `split_long_text()` — paragraph-aware chunking up to `--len` + +`SENTENCE_END_RE` handles edge cases like ordinal numbers, ellipses, and CJK punctuation. `SEPARATOR_LINE_RE` silently drops lines like `--- Ende ---`. + +### Model loading (`load_model`) +- `--lang en` → `ChatterboxTTS` (mono, always available) +- Other languages → `ChatterboxMultilingualTTS` (requires multilingual package; `HAS_MULTILINGUAL` flag guards import) +- `--t3-model v3` (default) or `v2` selects the multilingual T3 checkpoint +- Models are downloaded to `~/.cache/huggingface/` on first use (~2–3 GB) +- **Critical**: `attn_implementation = "eager"` is forced at import time because SDPA returns `None` attention weights, breaking the `AlignmentStreamAnalyzer` hook + +### Audio output (`PlaybackWorker`) +- Uses `sounddevice.OutputStream` with a callback at 48 kHz (PipeWire/PulseAudio standard) +- Internal producer thread converts Torch tensors → `CALLBACK_BLOCK`-sized (2048 samples) numpy arrays +- If `--speed != 1.0`: pyrubberband R3-Engine (`--fine` flag) stretches time without pitch change before resampling +- Resampling: `torchaudio.functional.resample(chunk, model_sr, 48000)` +- `PlaybackWorker.stop()` sends `None` sentinel into the queue and joins the thread + +### Two synthesis paths +- **`synthesize_non_streaming`**: generates each chunk fully, feeds finished tensors to `PlaybackWorker`, concatenates all wavs for `--save` +- **`synthesize_streaming`**: calls `model.generate_stream()` with `chunk_size`; each yielded audio sub-chunk goes directly to `PlaybackWorker`; marked experimental in docs + +## Planned extensions (Ideen/) + +The `Ideen/` folder documents a planned **REST/MCP bridge**: +- `tts_service.py` (FastAPI): `POST /speak`, `POST /stop`, `GET /health`, `GET /voices` +- `mcp_adapter.py`: thin MCP wrapper calling the REST API +- `chatterbox_backend.py`: imports `chatterbox_cli_v4.py` via `importlib` and calls `synthesize_non_streaming()` directly + +Key gaps to address before building the service: +1. **Stop/interrupt**: `PlaybackWorker.stop()` drains the audio queue, but a blocking `model.generate()` call cannot be interrupted mid-run. A `threading.Event`-based cancel token threaded through `synthesize_non_streaming` is the planned approach. +2. **Model caching**: `load_model()` reloads from disk on every call; a service needs a per-language singleton. +3. **Status object**: progress is `print()`-based; a service needs structured state. diff --git a/chatterbox_cli_v4.py b/chatterbox_cli_v4.py index 14c2a64..4ffc32d 100755 --- a/chatterbox_cli_v4.py +++ b/chatterbox_cli_v4.py @@ -10,6 +10,20 @@ import time from pathlib import Path from typing import List, Optional, Tuple +# --------------------------------------------------------------------------- +# Kooperativer Stop-Mechanismus +# --------------------------------------------------------------------------- +STOP_REQUESTED = threading.Event() + +def request_stop() -> None: + STOP_REQUESTED.set() + +def clear_stop() -> None: + STOP_REQUESTED.clear() + +def stop_requested() -> bool: + return STOP_REQUESTED.is_set() + import torch import torchaudio as ta @@ -556,10 +570,12 @@ class PlaybackWorker: PLAYBACK_RATE = 48000 # PipeWire/PulseAudio standard CALLBACK_BLOCK = 2048 # ~43 ms pro Callback-Block bei 48 kHz - def __init__(self, sample_rate: int, device: Optional[str] = "pulse", speed: float = 1.0): + def __init__(self, sample_rate: int, device: Optional[str] = "pulse", speed: float = 1.0, + stop_event: Optional[threading.Event] = None): self.sample_rate = sample_rate self.device = device self.speed = speed + self.stop_event = stop_event # Eingang: Torch-Tensoren vom TTS-Modell self.audio_queue: "queue.Queue[Optional[torch.Tensor]]" = queue.Queue() # Intern: fertig vorbereitete numpy-Blöcke für den Callback @@ -579,6 +595,9 @@ class PlaybackWorker: def _callback(self, outdata, frames, time_info, status): # Läuft im Audio-Thread: so schnell wie möglich, kein Lock nötig. + if self.stop_event and self.stop_event.is_set(): + outdata[:] = 0.0 + return try: data = self._block_queue.get_nowait() outdata[:, 0] = data @@ -593,6 +612,8 @@ class PlaybackWorker: remainder = np.zeros(0, dtype="float32") while True: + if self.stop_event and self.stop_event.is_set(): + break item = self.audio_queue.get() if item is None: break @@ -690,6 +711,7 @@ def synthesize_non_streaming( debug_delay: float = 0.0, t3_model: Optional[str] = None, pronunciation_dict: Optional[dict] = None, + stop_event: Optional[threading.Event] = None, ) -> Optional[Path]: if lang not in SUPPORTED_LANGS: raise ValueError( @@ -739,7 +761,8 @@ def synthesize_non_streaming( print(f"Ausgabe: {output_path}") if play_audio: - playback = PlaybackWorker(sample_rate=sr, device=audio_device, speed=speed) + playback = PlaybackWorker(sample_rate=sr, device=audio_device, speed=speed, + stop_event=stop_event) playback.start() else: playback = None @@ -747,6 +770,10 @@ def synthesize_non_streaming( wavs = [] try: for i, chunk in enumerate(chunks, start=1): + if stop_event and stop_event.is_set(): + if show_progress: + print("Abbruch angefordert – Synthese gestoppt.") + break if debug_delay > 0: if show_progress: print(f"[{i}/{len(chunks)}] Warte {debug_delay:.0f}s (debug_delay) ...") @@ -793,6 +820,7 @@ def synthesize_streaming( save_wav: bool = True, stream_chunk_size: int = 25, audio_device: Optional[str] = None, + stop_event: Optional[threading.Event] = None, ) -> Optional[Path]: if lang not in SUPPORTED_LANGS: raise ValueError( @@ -829,7 +857,7 @@ def synthesize_streaming( raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.") if play_audio: - playback = PlaybackWorker(sample_rate=sr, device=audio_device) + playback = PlaybackWorker(sample_rate=sr, device=audio_device, stop_event=stop_event) playback.start() else: playback = None @@ -853,6 +881,10 @@ def synthesize_streaming( try: for text_idx, text_chunk in enumerate(text_chunks, start=1): + if stop_event and stop_event.is_set(): + if show_progress: + print("Abbruch angefordert – Streaming gestoppt.") + break if show_progress: print(f"[Text {text_idx}/{len(text_chunks)}] Starte Streaming für {len(text_chunk)} Zeichen ...") @@ -866,6 +898,8 @@ def synthesize_streaming( ) for audio_idx, item in enumerate(stream_iter, start=1): + if stop_event and stop_event.is_set(): + break if isinstance(item, tuple) and len(item) == 2: audio_chunk, metrics = item else: @@ -944,6 +978,7 @@ def build_argparser() -> argparse.ArgumentParser: p.add_argument("--debug-delay", type=float, default=0.0, help="Sekunden Pause vor jedem Satz (simuliert langsame KI). Nur zum Testen.") p.add_argument("--t3-model", type=str, default="v3", help="Multilingual T3-Modell: 'v3' (default), 'v2' oder Dateiname.") p.add_argument("--no-conversation-mode", action="store_true", help="Ersten Chunk nicht künstlich kleiner machen (nur ohne --no-sentence-mode).") + p.add_argument("--stop", action="store_true", help="Globales Stop-Signal setzen (für Tests und Service-Integration).") return p @@ -951,6 +986,11 @@ def main() -> int: parser = build_argparser() args = parser.parse_args() + if args.stop: + request_stop() + print("Stop-Signal gesetzt.") + return 0 + try: text = read_input_text(args.text, args.input) device = get_device(args.device) @@ -970,6 +1010,8 @@ def main() -> int: raise FileNotFoundError(f"Aussprache-Dict nicht gefunden: {pron_path}") pronunciation_dict = json.loads(pron_path.read_text(encoding="utf-8")) + clear_stop() + if args.stream: out = synthesize_streaming( text=text, @@ -990,6 +1032,7 @@ def main() -> int: save_wav=save_wav, stream_chunk_size=args.stream_chunk_size, audio_device=args.audio_device, + stop_event=STOP_REQUESTED, ) else: out = synthesize_non_streaming( @@ -1015,6 +1058,7 @@ def main() -> int: debug_delay=args.debug_delay, t3_model=args.t3_model, pronunciation_dict=pronunciation_dict, + stop_event=STOP_REQUESTED, ) if out is not None: diff --git a/mcp_adapter.py b/mcp_adapter.py new file mode 100644 index 0000000..ca1562e --- /dev/null +++ b/mcp_adapter.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Chatterbox TTS – MCP-Adapter + +Setzt einen laufenden tts_service.py voraus (Standard: http://127.0.0.1:8000). + +Start (streamable-http, Port 8001 – für beliebige MCP-Clients): + python mcp_adapter.py + +Start (stdio – für Claude Code / Claude Desktop): + python mcp_adapter.py --stdio + +Claude Code Konfiguration (.claude/settings.json): + { + "mcpServers": { + "chatterbox-tts": { + "command": "python", + "args": ["/home/dschlueter/chatterbox-tts-cli/mcp_adapter.py", "--stdio"] + } + } + } + +Umgebungsvariable TTS_URL überschreibt die Service-Adresse: + TTS_URL=http://192.168.1.10:8000 python mcp_adapter.py --stdio +""" +from __future__ import annotations + +import argparse +import os + +import httpx +from mcp.server.fastmcp import FastMCP + +TTS_URL = os.environ.get("TTS_URL", "http://127.0.0.1:8000").rstrip("/") + +mcp = FastMCP( + "Chatterbox TTS", + instructions=( + "Lokaler Text-to-Speech-Service. Liest Texte auf Deutsch und 20+ weiteren " + "Sprachen vor. Unterstützt Voice Cloning, Geschwindigkeitsanpassung und " + "Aussprache-Wörterbücher." + ), + port=8001, +) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + +@mcp.tool() +async def speak( + text: str, + lang: str = "de", + voice: str | None = None, + interrupt: bool = False, + speed: float = 1.0, +) -> dict: + """Text als Sprache ausgeben. + + Reiht den Text in die Ausgabewarteschlange ein. Das Modell generiert + satzweise und beginnt sofort mit der Wiedergabe. + + Args: + text: Auszugebender Text (max. 4000 Zeichen). + lang: Sprachcode, z. B. 'de', 'en', 'fr'. Standard: 'de'. + voice: Optionaler Pfad zu einer WAV-Referenzdatei (10–30s) für + Voice Cloning. + interrupt: True = laufende Ausgabe sofort unterbrechen und diesen + Text vorgezogen abspielen. + speed: Wiedergabegeschwindigkeit (0.5–2.0). Pitch bleibt gleich. + """ + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post(f"{TTS_URL}/speak", json={ + "text": text, + "lang": lang, + "voice": voice, + "interrupt": interrupt, + "speed": speed, + }) + r.raise_for_status() + return r.json() + + +@mcp.tool() +async def stop() -> dict: + """Laufende Sprachausgabe sofort stoppen und Warteschlange leeren.""" + async with httpx.AsyncClient(timeout=5) as client: + r = await client.post(f"{TTS_URL}/stop") + r.raise_for_status() + return r.json() + + +@mcp.tool() +async def get_status() -> dict: + """Aktuellen Ausgabe-Status abfragen. + + Gibt zurück: laufender Job (mit Chunk-Fortschritt), Queue-Länge und + die letzten abgeschlossenen Jobs. + """ + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{TTS_URL}/status") + r.raise_for_status() + return r.json() + + +@mcp.tool() +async def list_voices() -> dict: + """Unterstützte Sprachen und Hinweise zu Voice Cloning abfragen.""" + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(f"{TTS_URL}/voices") + r.raise_for_status() + return r.json() + + +# --------------------------------------------------------------------------- +# Einstiegspunkt +# --------------------------------------------------------------------------- +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Chatterbox TTS MCP-Adapter") + parser.add_argument( + "--stdio", action="store_true", + help="stdio-Transport (für Claude Code / Claude Desktop)", + ) + parser.add_argument("--host", default="127.0.0.1", + help="Host für streamable-http (Standard: 127.0.0.1)") + parser.add_argument("--port", type=int, default=8001, + help="Port für streamable-http (Standard: 8001)") + args = parser.parse_args() + + if args.stdio: + mcp.run() # stdio ist der Default-Transport + else: + mcp.run(transport="streamable-http", host=args.host, port=args.port) diff --git a/requirements.txt b/requirements.txt index 6408a4d..3b709db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,13 @@ sounddevice>=0.4.0 pyrubberband>=0.4.0 # rubberband-cli muss zusätzlich als Systempakete installiert sein: # sudo apt install rubberband-cli + +# HTTP-Service (Phase 2) +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 + +# HTTP-Client für MCP-Adapter (Phase 3) +httpx>=0.28.0 + +# MCP-Adapter (Phase 3) +mcp>=1.0.0 diff --git a/tts_service.py b/tts_service.py new file mode 100644 index 0000000..0b2a6a5 --- /dev/null +++ b/tts_service.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +""" +Chatterbox TTS – lokaler HTTP-Service + +Start: + uvicorn tts_service:app --host 127.0.0.1 --port 8000 + +Endpunkte: + POST /speak – Text in Warteschlange einreihen + POST /stop – laufende Ausgabe abbrechen, Queue leeren + GET /health – Service-Status + GET /status – aktueller Job + Queue-Länge + GET /voices – unterstützte Sprachen +""" +from __future__ import annotations + +import queue +import sys +import threading +import uuid +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Optional + +# CLI-Modul aus demselben Verzeichnis laden +sys.path.insert(0, str(Path(__file__).parent)) +import chatterbox_cli_v4 as tts # noqa: E402 + +import torch +import torchaudio as ta +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Gerät einmalig bestimmen +# --------------------------------------------------------------------------- +_DEVICE = tts.get_device(None) + +# --------------------------------------------------------------------------- +# Modell-Cache (lang, t3_model) → (model, model_kind, sr) +# --------------------------------------------------------------------------- +_model_cache: dict[tuple, tuple] = {} +_model_lock = threading.Lock() + + +def _get_or_load_model(lang: str, t3_model: str) -> tuple: + key = (lang, t3_model) + with _model_lock: + if key not in _model_cache: + _model_cache[key] = tts.load_model(lang, _DEVICE, t3_model=t3_model) + return _model_cache[key] + + +# --------------------------------------------------------------------------- +# Job-Datenmodell +# --------------------------------------------------------------------------- +class JobStatus(str, Enum): + pending = "pending" + running = "running" + done = "done" + cancelled = "cancelled" + error = "error" + + +@dataclass +class SpeakJob: + id: str + text: str + lang: str + t3_model: str + voice: Optional[str] + speed: float + audio_device: str + max_len: int + save_wav: bool + output_path: Optional[str] + pronunciation_dict: Optional[dict] + session_id: Optional[str] + status: JobStatus = field(default=JobStatus.pending) + text_preview: str = field(default="") + chunks_total: int = 0 + chunks_done: int = 0 + error: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Worker-Thread +# --------------------------------------------------------------------------- +_job_queue: queue.Queue[SpeakJob] = queue.Queue() +_current_job: Optional[SpeakJob] = None +_state_lock = threading.Lock() +_recent_jobs: list[SpeakJob] = [] +_MAX_RECENT = 20 + + +def _worker() -> None: + global _current_job + + while True: + job = _job_queue.get() + + with _state_lock: + _current_job = job + job.status = JobStatus.running + + tts.clear_stop() + + try: + model, model_kind, sr = _get_or_load_model(job.lang, job.t3_model) + + raw = tts.clean_raw_text(job.text) + raw_chunks = tts.split_into_sentences(raw, max_len=job.max_len) + chunks = [ + tts.preprocess_tts_text(c, lang=job.lang, + pronunciation_dict=job.pronunciation_dict) + for c in raw_chunks + ] + chunks = [c for c in chunks if c.strip()] + + job.chunks_total = len(chunks) + job.text_preview = job.text[:80] + + playback = tts.PlaybackWorker( + sample_rate=sr, + device=job.audio_device, + speed=job.speed, + stop_event=tts.STOP_REQUESTED, + ) + playback.start() + + wavs: list[torch.Tensor] = [] + try: + for chunk in chunks: + if tts.stop_requested(): + break + wav = tts.generate_chunk(model, model_kind, chunk, job.lang, job.voice) + wavs.append(wav) + playback.put(wav) + job.chunks_done += 1 + finally: + playback.stop() + + if job.save_wav and job.output_path and wavs: + out = Path(job.output_path) + out.parent.mkdir(parents=True, exist_ok=True) + final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1) + ta.save(str(out), final, sr) + + job.status = ( + JobStatus.cancelled if tts.stop_requested() else JobStatus.done + ) + + except Exception as exc: # noqa: BLE001 + job.status = JobStatus.error + job.error = str(exc) + + finally: + with _state_lock: + _current_job = None + _recent_jobs.append(job) + if len(_recent_jobs) > _MAX_RECENT: + _recent_jobs.pop(0) + _job_queue.task_done() + + +_worker_thread = threading.Thread(target=_worker, daemon=True, name="tts-worker") +_worker_thread.start() + + +# --------------------------------------------------------------------------- +# API-Modelle +# --------------------------------------------------------------------------- +class SpeakRequest(BaseModel): + text: str = Field(min_length=1, max_length=4000) + lang: str = "de" + voice: Optional[str] = None + interrupt: bool = False + speed: float = Field(default=1.0, ge=0.5, le=2.0) + t3_model: str = "v3" + audio_device: str = "pulse" + max_len: int = Field(default=400, ge=50, le=1000) + save_wav: bool = False + output_path: Optional[str] = None + session_id: Optional[str] = None + pronunciation_dict: Optional[dict] = None + + +def _job_to_dict(j: SpeakJob) -> dict: + return { + "id": j.id, + "status": j.status, + "lang": j.lang, + "text_preview": j.text_preview, + "chunks_total": j.chunks_total, + "chunks_done": j.chunks_done, + "error": j.error, + } + + +def _drain_queue() -> None: + while not _job_queue.empty(): + try: + _job_queue.get_nowait() + _job_queue.task_done() + except queue.Empty: + break + + +# --------------------------------------------------------------------------- +# FastAPI-App +# --------------------------------------------------------------------------- +app = FastAPI(title="Chatterbox TTS Service", version="1.0") + + +@app.get("/health") +def health(): + return {"status": "ok", "device": _DEVICE} + + +@app.get("/voices") +def voices(): + return { + "languages": sorted(tts.SUPPORTED_LANGS), + "note": "Voice cloning via 'voice' field (WAV-Pfad, 10–30s Aufnahme)", + } + + +@app.post("/speak") +def speak(req: SpeakRequest): + if req.lang not in tts.SUPPORTED_LANGS: + raise HTTPException(status_code=422, + detail=f"Sprache nicht unterstützt: {req.lang}") + if req.voice and not Path(req.voice).exists(): + raise HTTPException(status_code=422, + detail=f"Voice-Datei nicht gefunden: {req.voice}") + + if req.interrupt: + tts.request_stop() + _drain_queue() + + job = SpeakJob( + id=str(uuid.uuid4()), + text=req.text, + lang=req.lang, + t3_model=req.t3_model, + voice=req.voice, + speed=req.speed, + audio_device=req.audio_device, + max_len=req.max_len, + save_wav=req.save_wav, + output_path=req.output_path, + pronunciation_dict=req.pronunciation_dict, + session_id=req.session_id, + ) + _job_queue.put(job) + + return { + "job_id": job.id, + "status": job.status, + "queue_position": _job_queue.qsize(), + } + + +@app.post("/stop") +def stop(): + tts.request_stop() + _drain_queue() + return {"stopped": True} + + +@app.get("/status") +def status(): + with _state_lock: + cur = _current_job + recent = list(_recent_jobs) + + return { + "current_job": _job_to_dict(cur) if cur else None, + "queue_length": _job_queue.qsize(), + "recent_jobs": [_job_to_dict(j) for j in reversed(recent)], + } From d1971049ceb71e55d7be7ee1ebfa9219cf33b40f Mon Sep 17 00:00:00 2001 From: dschlueter Date: Sat, 16 May 2026 10:19:00 +0200 Subject: [PATCH 3/4] Add HTTP service, MCP adapter, systemd autostart; fix bugs and docs - chatterbox_cli_v4.py: cooperative stop/interrupt via threading.Event; fix force_split_sentence (word boundary instead of mid-word cut); fix synthesize_streaming normalization order (split before preprocess) - tts_service.py: FastAPI service with job queue, model cache, worker thread; LAN-accessible on 0.0.0.0:9999; audio_device default None (auto) - mcp_adapter.py: MCP adapter (stdio + streamable-http) wrapping REST API; update docstring and default TTS_URL to port 9999 - requirements.txt: add fastapi, uvicorn, httpx, mcp - README.md, BEDIENUNGSANLEITUNG.md: document service, MCP, AI integrations (Claude, Ollama, Open WebUI, llama.cpp, Home Assistant), systemd autostart - CLAUDE.md: reflect current architecture (service + adapter now implemented) Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 104 +++++++++++- CLAUDE.md | 113 +++++++++---- README.md | 357 ++++++++++++++++++++++++++++++++--------- chatterbox_cli_v4.py | 31 ++-- mcp_adapter.py | 19 +-- requirements.txt | 22 ++- tts_service.py | 6 +- 7 files changed, 500 insertions(+), 152 deletions(-) diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 2cff9c1..e1c131d 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -13,13 +13,32 @@ Es wandelt geschriebenen Text in natürlich klingende Sprache um. --- -## Das Programm starten +## Automatischer Start im Hintergrund -Öffne ein Terminal und gib folgende Befehle ein: +Der Sprach-Service startet automatisch, sobald du dich am Computer anmeldest. +Du musst nichts weiter tun — er läuft im Hintergrund und wartet auf Anfragen. + +Ob der Service läuft, prüfst du so: + +```bash +systemctl --user status chatterbox-tts +``` + +Bei Problemen neu starten: + +```bash +systemctl --user restart chatterbox-tts +``` + +--- + +## Das Kommandozeilen-Programm starten + +Für die direkte Nutzung über das Terminal: ```bash conda activate chatterbox -cd ~/Python_Programs/chatterbox +cd ~/chatterbox-tts-cli ``` --- @@ -85,7 +104,7 @@ python chatterbox_cli_v4.py --lang en --text "Good morning, how are you?" Mit `--speed` kann man einstellen, wie schnell der Text gesprochen wird. - `1.0` = normale Geschwindigkeit (Standard) -- `0.85` = etwas langsamer — gut für ältere Hörer +- `0.85` = etwas langsamer — gut für entspanntes Zuhören - `0.75` = deutlich langsamer - `1.2` = etwas schneller @@ -149,6 +168,73 @@ python chatterbox_cli_v4.py --lang de \ --- +## Den Service aus dem Netzwerk nutzen + +Der Service ist im gesamten Heimnetzwerk erreichbar — zum Beispiel vom Handy, +Tablet oder einem anderen Computer. + +**Text vorlesen lassen** (aus jedem Gerät im Netzwerk): + +```bash +curl -X POST http://COMPUTER-IP:9999/speak \ + -H "Content-Type: application/json" \ + -d '{"text": "Hallo aus dem Netzwerk", "lang": "de"}' +``` + +`COMPUTER-IP` ersetzen durch die IP-Adresse dieses Computers (z. B. `192.168.1.42`). + +**Aktuelle IP-Adresse herausfinden:** +```bash +hostname -I +``` + +**Ausgabe stoppen:** +```bash +curl -X POST http://COMPUTER-IP:9999/stop +``` + +--- + +## KI-Assistenten lassen vorlesen + +Wenn du einen KI-Assistenten auf diesem oder einem anderen Gerät nutzt, +kann er den TTS-Service direkt ansprechen: + +### Claude (Claude Code / Claude Desktop) + +Claude ist bereits mit dem TTS-Service verbunden. Du kannst Claude einfach bitten, +etwas vorzulesen — er ruft den Service automatisch auf. + +Beispiel-Anfrage an Claude: +> „Lies mir bitte diesen Text vor: ..." + +### Home Assistant + +In der `configuration.yaml` folgendes eintragen: + +```yaml +rest_command: + tts_sprechen: + url: "http://COMPUTER-IP:9999/speak" + method: POST + content_type: "application/json" + payload: '{"text": "{{ text }}", "lang": "de"}' +``` + +Danach in einer Automation verwendbar: +```yaml +service: rest_command.tts_sprechen +data: + text: "Die Waschmaschine ist fertig." +``` + +### Ollama / LM Studio / Open WebUI + +Lokale KI-Modelle (z. B. llama, qwen) können über eine kleine Hilfsklasse mit dem +Service verbunden werden — Details in der `README.md`. + +--- + ## Typischer Arbeitsablauf 1. Text in einem Editor schreiben und als `.txt`-Datei speichern @@ -175,11 +261,17 @@ python chatterbox_cli_v4.py --lang de \ **Kein Ton zu hören:** ```bash -# Ausgabegerät prüfen python -c "import sounddevice; print(sounddevice.query_devices())" ``` Dann `--audio-device pulse` oder das passende Gerät angeben. +**Service antwortet nicht:** +```bash +systemctl --user restart chatterbox-tts +# Warte 5 Sekunden, dann: +curl http://localhost:9999/health +``` + **„Modell nicht gefunden":** Beim ersten Start wird das Modell heruntergeladen (~2 GB). Sicherstellen, dass eine Internetverbindung besteht. @@ -198,3 +290,5 @@ kann 30–60 Sekunden brauchen. Mit GPU (CUDA) dauert es ca. 5–10 Sekunden. nicht immer perfekt — mit der Aussprache-Datei lässt sich das korrigieren. - Das Programm liest alles vor, was in der Datei steht — also auch Überschriften und Metadaten wie „Schlagzeile:" oder „Stand:". +- Eine laufende Ausgabe kann erst am Ende des aktuellen Satzes unterbrochen + werden, nicht sofort mitten im Wort. diff --git a/CLAUDE.md b/CLAUDE.md index 32e2c30..6f0a870 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,56 +31,105 @@ python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --inp No build step, no test suite, no linter configuration — this is a single-file script. +## Running the HTTP Service + +```bash +# Läuft als systemd-User-Service (Autostart beim Login): +systemctl --user status chatterbox-tts +systemctl --user restart chatterbox-tts +journalctl --user -u chatterbox-tts -f + +# Manuell starten (Port 9999, LAN-weit erreichbar): +uvicorn tts_service:app --host 0.0.0.0 --port 9999 + +# Health-Check: +curl http://127.0.0.1:9999/health +``` + +Endpunkte: `POST /speak`, `POST /stop`, `GET /health`, `GET /status`, `GET /voices` + +## Running the MCP Adapter + +```bash +# stdio (Claude Code / Claude Desktop) — bereits in ~/.claude.json konfiguriert: +python mcp_adapter.py --stdio + +# HTTP-Transport (Port 8001): +python mcp_adapter.py + +# Anderen TTS-Service ansprechen: +TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio +``` + ## Architecture -Everything lives in `chatterbox_cli_v4.py`. The processing pipeline is: +### Files -**Text input → normalization → chunking → TTS generation → audio output** +| Datei | Funktion | +|-------|----------| +| `chatterbox_cli_v4.py` | Kern-CLI und alle Hilfsfunktionen; wird von `tts_service.py` importiert | +| `tts_service.py` | FastAPI-Service mit Job-Queue und Worker-Thread | +| `mcp_adapter.py` | MCP-Wrapper über die REST-API | + +### CLI pipeline (`chatterbox_cli_v4.py`) + +**Text input → `clean_raw_text` → chunking → `preprocess_tts_text` per chunk → TTS generation → audio output** + +Reihenfolge ist kritisch: erst splitten (Satzgrenzen auf Rohtext erkennen), dann normalisieren (Akronym-Punkte würden sonst falsche Satzgrenzen erzeugen). + +### Stop/Interrupt + +Modul-globales `threading.Event`: +```python +STOP_REQUESTED = threading.Event() +request_stop() # setzt das Event +clear_stop() # löscht es vor jedem neuen Job +stop_requested() # abfragen +``` +`PlaybackWorker` und beide Synthesize-Funktionen prüfen das Event an Chunk-Grenzen. Ein laufendes `model.generate()` kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen) — der Abbruch greift am nächsten Chunk. ### Text normalization (`preprocess_tts_text`) -Applied per chunk before synthesis. Order matters: -1. Pronunciation dict substitutions (before acronym expansion, so proper names are caught first) + +1. Pronunciation dict (vor Akronym-Expansion, damit Eigennamen zuerst greifen) 2. Unit normalization (120 km/h → "120 Kilometer pro Stunde") 3. Time normalization (14:58 → "vierzehn Uhr achtundfünfzig") 4. Year normalization (2026 → "zweitausendsechsundzwanzig") -5. Acronym spelling (ARD → "Ah Er De"; skips entries in `NON_SPELLED_ACRONYMS`) +5. Acronym spelling (ARD → "Ah Er De"; `NON_SPELLED_ACRONYMS` ausgenommen) -`DEFAULT_PRONUNCIATION_DE` contains built-in German phonetic approximations (e.g. Xi → "Schi"). +`DEFAULT_PRONUNCIATION_DE` enthält eingebaute deutsche Lautschrift-Näherungen (z. B. Xi → "Schi"). ### Text chunking -Three modes (chosen by CLI flags): -- **sentence_mode** (default): `split_into_sentences()` — one sentence per TTS call, lowest latency to first audio -- **conversation_mode**: `split_for_conversation()` — first chunk is small (`--first-chunk-len`, default 80 chars), rest up to `--len` (400) -- **plain**: `split_long_text()` — paragraph-aware chunking up to `--len` -`SENTENCE_END_RE` handles edge cases like ordinal numbers, ellipses, and CJK punctuation. `SEPARATOR_LINE_RE` silently drops lines like `--- Ende ---`. +Drei Modi (CLI-Flags): +- **sentence_mode** (default): `split_into_sentences()` — ein Satz pro TTS-Call, geringste Latenz +- **conversation_mode**: `split_for_conversation()` — erster Chunk klein (`--first-chunk-len`, default 80), Rest bis `--len` (400) +- **plain**: `split_long_text()` — absatzbasiertes Chunking bis `--len` + +`force_split_sentence` sucht bei Überlänge erst vorwärts zum nächsten Wortende — kein Schneiden mitten im Wort. ### Model loading (`load_model`) -- `--lang en` → `ChatterboxTTS` (mono, always available) -- Other languages → `ChatterboxMultilingualTTS` (requires multilingual package; `HAS_MULTILINGUAL` flag guards import) -- `--t3-model v3` (default) or `v2` selects the multilingual T3 checkpoint -- Models are downloaded to `~/.cache/huggingface/` on first use (~2–3 GB) -- **Critical**: `attn_implementation = "eager"` is forced at import time because SDPA returns `None` attention weights, breaking the `AlignmentStreamAnalyzer` hook + +- `--lang en` → `ChatterboxTTS` (mono, immer verfügbar) +- Andere Sprachen → `ChatterboxMultilingualTTS` (`HAS_MULTILINGUAL`-Flag bewacht Import) +- `--t3-model v3` (default) oder `v2` wählt den multilingualen T3-Checkpoint +- Modelle werden in `~/.cache/huggingface/` gecacht (~2–3 GB) +- **Kritisch**: `attn_implementation = "eager"` wird beim Import erzwungen — SDPA gibt `None`-Attention-Weights zurück und bricht den `AlignmentStreamAnalyzer`-Hook ### Audio output (`PlaybackWorker`) -- Uses `sounddevice.OutputStream` with a callback at 48 kHz (PipeWire/PulseAudio standard) -- Internal producer thread converts Torch tensors → `CALLBACK_BLOCK`-sized (2048 samples) numpy arrays -- If `--speed != 1.0`: pyrubberband R3-Engine (`--fine` flag) stretches time without pitch change before resampling -- Resampling: `torchaudio.functional.resample(chunk, model_sr, 48000)` -- `PlaybackWorker.stop()` sends `None` sentinel into the queue and joins the thread + +- `sounddevice.OutputStream` mit Callback bei 48 kHz (PipeWire/PulseAudio-Standard) +- Interner Producer-Thread: Torch-Tensoren → `CALLBACK_BLOCK`-große (2048 Samples) numpy-Arrays +- `--speed != 1.0`: pyrubberband R3-Engine (`--fine`) streckt Zeit ohne Pitch-Änderung, dann Resampling via `torchaudio.functional.resample(chunk, model_sr, 48000)` +- `PlaybackWorker.stop()` schickt `None`-Sentinel in die Queue und jointed den Thread ### Two synthesis paths -- **`synthesize_non_streaming`**: generates each chunk fully, feeds finished tensors to `PlaybackWorker`, concatenates all wavs for `--save` -- **`synthesize_streaming`**: calls `model.generate_stream()` with `chunk_size`; each yielded audio sub-chunk goes directly to `PlaybackWorker`; marked experimental in docs -## Planned extensions (Ideen/) +- **`synthesize_non_streaming`**: generiert jeden Chunk vollständig, füttert fertige Tensoren in `PlaybackWorker`, concateniert alle WAVs für `--save` +- **`synthesize_streaming`**: ruft `model.generate_stream()` mit `chunk_size` auf; jeder Audio-Sub-Chunk geht direkt in `PlaybackWorker`; experimentell -The `Ideen/` folder documents a planned **REST/MCP bridge**: -- `tts_service.py` (FastAPI): `POST /speak`, `POST /stop`, `GET /health`, `GET /voices` -- `mcp_adapter.py`: thin MCP wrapper calling the REST API -- `chatterbox_backend.py`: imports `chatterbox_cli_v4.py` via `importlib` and calls `synthesize_non_streaming()` directly +### HTTP Service (`tts_service.py`) -Key gaps to address before building the service: -1. **Stop/interrupt**: `PlaybackWorker.stop()` drains the audio queue, but a blocking `model.generate()` call cannot be interrupted mid-run. A `threading.Event`-based cancel token threaded through `synthesize_non_streaming` is the planned approach. -2. **Model caching**: `load_model()` reloads from disk on every call; a service needs a per-language singleton. -3. **Status object**: progress is `print()`-based; a service needs structured state. +- **Modell-Cache**: `_model_cache: dict[(lang, t3_model), (model, kind, sr)]` — einmal laden, halten; Thread-sicher via `_model_lock` +- **Job-Queue**: `queue.Queue[SpeakJob]` mit einzelnem Worker-Thread; verhindert parallelen GPU/Audio-Zugriff +- **`SpeakRequest.interrupt`**: ruft `request_stop()` + `_drain_queue()` vor dem Einreihen auf +- **Status**: `_current_job`, `_recent_jobs` (max. 20) via `_state_lock` thread-safe lesbar diff --git a/README.md b/README.md index fb68309..fb5fd3a 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,320 @@ # chatterbox-tts-cli -Ein kommandozeilenbasierter TTS-Assistent (Text-to-Speech) auf Basis von +Ein lokaler Text-to-Speech-Assistent auf Basis von [Chatterbox TTS](https://github.com/resemble-ai/chatterbox) (Resemble AI). -Optimiert für deutsche Sprache und den Einsatz als Audio-Vorlesehilfe, z. B. -für Senioren oder Accessibility-Anwendungen. +Optimiert für deutsche Sprache; nutzbar als Kommandozeilen-Tool, als lokaler +HTTP-Service und als MCP-Server für KI-Assistenten. ## Features -- **Satz-für-Satz-Streaming** — gibt den ersten Satz aus, während die nächsten - bereits generiert werden; minimale Latenz -- **Lückenlose Audiowiedergabe** — Callback-basierter OutputStream mit - vorgefertigten Blöcken; keine Unterbrechungen zwischen Sätzen -- **Geschwindigkeitsanpassung** — pitch-erhaltende Zeitstreckung via - pyrubberband (R3-Engine); konfigurierbar per `--speed` +- **Satz-für-Satz-Ausgabe** — gibt den ersten Satz aus, während die nächsten bereits generiert werden; minimale Latenz +- **Lückenlose Audiowiedergabe** — Callback-basierter OutputStream; keine Unterbrechungen zwischen Sätzen +- **Geschwindigkeitsanpassung** — pitch-erhaltende Zeitstreckung via pyrubberband (R3-Engine); `--speed 0.5`–`2.0` - **Voice Cloning** — optionale WAV-Referenz für Akzent und Klang -- **Mehrsprachig** — Deutsch, Englisch und 20+ weitere Sprachen via - `ChatterboxMultilingualTTS` (erfordert Multilingual-Setup, s. u.) -- **Deutsche Textnormalisierung** - - Abkürzungen: ARD → "Ah Er De", YMCA → "Ypsilon Em Tse Ah" - - Komposita: US-Präsident → "U Es Präsident" - - Uhrzeiten: 14:58 → "vierzehn Uhr achtundfünfzig" - - Jahreszahlen: 2026 → "zweitausendsechsundzwanzig" - - Einheiten: 120 km/h → "120 Kilometer pro Stunde" -- **Konfigurierbares Aussprache-Wörterbuch** — Eigennamen und Fremdwörter - per JSON-Datei phonetisch überschreiben -- **Automatische Satz-Erkennung** — intelligentes Splitting inkl. - Ordinalzahlen, Paragraphen und Trennzeilen +- **Mehrsprachig** — Deutsch, Englisch und 20+ weitere Sprachen via `ChatterboxMultilingualTTS` +- **Deutsche Textnormalisierung** — Abkürzungen (ARD → „Ah Er De"), Uhrzeiten (14:58 → „vierzehn Uhr achtundfünfzig"), Jahreszahlen, Einheiten, Aussprache-Wörterbuch +- **HTTP-Service** — FastAPI-Service mit Job-Queue, Stop/Interrupt, Status-Endpunkt +- **MCP-Adapter** — direkte Integration in Claude Code, Claude Desktop und andere MCP-Hosts +- **Systemd-Autostart** — Service startet automatisch beim Login + +--- ## Systemvoraussetzungen - Python 3.11+ -- CUDA-GPU empfohlen (RTX 3070 oder besser; CPU möglich aber langsam) -- Linux mit PipeWire oder PulseAudio (für `--audio-device pulse`) -- `rubberband-cli` (nur wenn `--speed` != 1.0 genutzt wird): +- CUDA-GPU empfohlen (RTX 3070 oder besser; CPU möglich, aber langsam) +- Linux mit PipeWire oder PulseAudio +- `rubberband-cli` für Geschwindigkeitsanpassung: ```bash sudo apt install rubberband-cli ``` +--- + ## Installation ```bash -# 1. Conda-Umgebung erstellen (empfohlen) +# 1. Conda-Umgebung conda create -n chatterbox python=3.11 conda activate chatterbox -# 2. PyTorch mit CUDA installieren (Beispiel für CUDA 12.4) +# 2. PyTorch mit CUDA (Beispiel CUDA 12.4) pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124 -# 3. Chatterbox und weitere Abhängigkeiten -pip install chatterbox-tts sounddevice pyrubberband - -# 4. Skript herunterladen -# chatterbox_cli_v4.py in das Arbeitsverzeichnis legen +# 3. Alle Abhängigkeiten +pip install -r requirements.txt ``` -### Multilingual-Setup (für Deutsch und andere Nicht-Englisch-Sprachen) +Beim ersten Start mit `--lang de` werden Modelle automatisch heruntergeladen (~2–3 GB, `~/.cache/huggingface/`). -Das Standard-Paket `chatterbox-tts` enthält die Multilingual-Unterstützung -noch nicht vollständig. Notwendige Schritte: +--- + +## Kommandozeilen-CLI ```bash -# Multilingual-Modell herunterladen (beim ersten Start automatisch) -# Modell-Auswahl: v3 (Standard, besser) oder v2 -# Wird in ~/.cache/huggingface/ gespeichert +conda activate chatterbox + +# Deutschen Text vorlesen +python chatterbox_cli_v4.py --lang de --input text.txt + +# Mit Voice Cloning +python chatterbox_cli_v4.py --lang de --voice stimme.wav --input text.txt + +# Text direkt übergeben +python chatterbox_cli_v4.py --lang en --text "Hello, how are you?" + +# Langsamer sprechen (pitch bleibt gleich) +python chatterbox_cli_v4.py --lang de --speed 0.85 --input text.txt + +# Nur speichern, nicht abspielen +python chatterbox_cli_v4.py --lang de --no-play --output ausgabe.wav --input text.txt + +# Aussprache-Wörterbuch +python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --input text.txt ``` -Beim ersten Start mit `--lang de` werden die Modelle automatisch heruntergeladen -(ca. 2–3 GB). - -## Schnellstart - -```bash -# Deutschen Text vorlesen (aus Datei) -python chatterbox_cli_v4.py --lang de --input mein_text.txt - -# Mit eigener Stimme (Voice Cloning) -python chatterbox_cli_v4.py --lang de \ - --voice meine_stimme.wav \ - --input mein_text.txt - -# Etwas langsamer sprechen -python chatterbox_cli_v4.py --lang de --speed 0.85 --input mein_text.txt - -# Englisch -python chatterbox_cli_v4.py --lang en --text "Hello, how are you today?" -``` - -## Optionen +### CLI-Optionen | Option | Standard | Beschreibung | |--------|----------|--------------| | `--text TEXT` | — | Text direkt als Argument | -| `--input DATEI` | — | UTF-8-Textdatei als Eingabe | +| `--input DATEI` | — | UTF-8-Textdatei | | `--lang CODE` | `de` | Sprachcode (de, en, fr, es, …) | -| `--voice DATEI.wav` | — | Referenz-WAV für Voice Cloning | -| `--speed 0.85` | `1.0` | Geschwindigkeit (0.7–1.3); pitch bleibt gleich | +| `--voice DATEI.wav` | — | Referenz-WAV für Voice Cloning (10–30 s) | +| `--speed N` | `1.0` | Wiedergabegeschwindigkeit (0.5–2.0) | | `--audio-device` | `pulse` | Ausgabegerät (z. B. `pulse`, `default`) | | `--t3-model` | `v3` | Multilingual-Modell: `v3` oder `v2` | | `--acronym-mode` | `german` | Akronym-Modus: `german`, `space`, `period_space` | | `--pronunciation-dict` | — | JSON-Datei mit Aussprache-Substitutionen | | `--save` | nein | WAV-Datei speichern | | `--output DATEI.wav` | — | Ausgabepfad (impliziert `--save`) | -| `--no-play` | — | Nur speichern, nicht abspielen | -| `--no-sentence-mode` | — | Text als Ganzes statt satzweise verarbeiten | -| `--debug-delay N` | `0` | Pause in Sekunden vor jedem Satz (zum Testen) | -| `--speed` | `1.0` | Sprechgeschwindigkeit | +| `--no-play` | — | Nicht live abspielen | +| `--no-sentence-mode` | — | Größere Chunks statt satzweise | +| `--stream` | — | Streaming-Modus (experimentell) | +| `--no-progress` | — | Weniger Konsolenausgabe | +| `--debug-delay N` | `0` | Pause vor jedem Satz (zum Testen) | +| `--stop` | — | Laufende Ausgabe abbrechen | + +--- + +## HTTP-Service (`tts_service.py`) + +FastAPI-Service mit Job-Queue und Worker-Thread. Startet automatisch via systemd. + +```bash +# Manueller Start +uvicorn tts_service:app --host 0.0.0.0 --port 9999 + +# Systemd (Autostart, läuft bereits) +systemctl --user status chatterbox-tts +systemctl --user restart chatterbox-tts +journalctl --user -u chatterbox-tts -f +``` + +### Endpunkte + +| Methode | Pfad | Funktion | +|---------|------|----------| +| `POST` | `/speak` | Text in Queue einreihen | +| `POST` | `/stop` | Ausgabe abbrechen, Queue leeren | +| `GET` | `/health` | Service-Status und Gerät | +| `GET` | `/status` | Aktueller Job, Queue-Länge, letzte Jobs | +| `GET` | `/voices` | Unterstützte Sprachen | + +### `/speak` Request-Body + +```json +{ + "text": "Hallo Welt", + "lang": "de", + "voice": null, + "interrupt": false, + "speed": 1.0, + "t3_model": "v3", + "audio_device": null, + "max_len": 400, + "save_wav": false, + "output_path": null, + "pronunciation_dict": null +} +``` + +```bash +# Beispiel +curl -X POST http://localhost:9999/speak \ + -H "Content-Type: application/json" \ + -d '{"text": "Hallo Welt", "lang": "de"}' + +# Aus dem LAN +curl -X POST http://192.168.x.x:9999/speak \ + -H "Content-Type: application/json" \ + -d '{"text": "Text aus dem Netzwerk", "lang": "de"}' + +# Laufende Ausgabe unterbrechen +curl -X POST http://localhost:9999/speak \ + -d '{"text": "Wichtiger Text", "lang": "de", "interrupt": true}' \ + -H "Content-Type: application/json" + +# Stoppen +curl -X POST http://localhost:9999/stop +``` + +--- + +## MCP-Adapter (`mcp_adapter.py`) + +Dünner Wrapper über die REST-API für MCP-fähige Hosts. + +```bash +# stdio-Modus (Claude Code / Claude Desktop) +python mcp_adapter.py --stdio + +# HTTP-Modus (andere MCP-Clients, Port 8001) +python mcp_adapter.py + +# Anderen TTS-Service ansprechen +TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio +``` + +### MCP-Tools + +| Tool | Parameter | Funktion | +|------|-----------|----------| +| `speak` | text, lang, voice, interrupt, speed | Text ausgeben | +| `stop` | — | Ausgabe stoppen | +| `get_status` | — | Aktuellen Job abfragen | +| `list_voices` | — | Sprachen auflisten | + +### Claude Code Konfiguration + +Bereits eingerichtet via `claude mcp add --scope user`. Zur manuellen Einrichtung: + +```bash +claude mcp add --scope user chatterbox-tts \ + /home/dschlueter/miniforge3/envs/chatterbox/bin/python \ + /home/dschlueter/chatterbox-tts-cli/mcp_adapter.py --stdio +``` + +### Claude Desktop (`~/.config/claude/claude_desktop_config.json`) + +```json +{ + "mcpServers": { + "chatterbox-tts": { + "command": "/home/dschlueter/miniforge3/envs/chatterbox/bin/python", + "args": ["/home/dschlueter/chatterbox-tts-cli/mcp_adapter.py", "--stdio"] + } + } +} +``` + +--- + +## Integration mit KI-Tools + +### Claude Code / Claude Desktop — MCP (fertig eingerichtet) + +Claude kann direkt die Tools `speak`, `stop`, `get_status` und `list_voices` aufrufen. +Kein weiterer Setup nötig. + +### Ollama (llama3.2, qwen2.5, mistral-nemo u. a.) + +Modelle mit Tool-Support können den REST-Service über Function Calling ansprechen: + +```python +import ollama, httpx + +tools = [{ + "type": "function", + "function": { + "name": "speak", + "description": "Text als Sprache ausgeben", + "parameters": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "lang": {"type": "string", "default": "de"}, + "speed": {"type": "number", "default": 1.0}, + }, + "required": ["text"], + }, + }, +}] + +resp = ollama.chat(model="qwen2.5", messages=[{"role": "user", "content": "..."}], tools=tools) + +for call in resp.message.tool_calls or []: + if call.function.name == "speak": + httpx.post("http://127.0.0.1:9999/speak", json=call.function.arguments) +``` + +### Open WebUI + +Im Open-WebUI-Menü unter *Tools* eine neue Python-Klasse anlegen: + +```python +import requests + +class Tools: + def speak(self, text: str, lang: str = "de") -> str: + """Text als Sprache ausgeben.""" + r = requests.post("http://127.0.0.1:9999/speak", + json={"text": text, "lang": lang}, timeout=10) + return r.json().get("job_id", "error") + + def stop(self) -> str: + """Laufende Sprachausgabe stoppen.""" + requests.post("http://127.0.0.1:9999/stop", timeout=5) + return "stopped" +``` + +### LM Studio + +LM Studio bietet einen OpenAI-kompatiblen Endpunkt. Die Tool-Definition entspricht dem +Ollama-Beispiel oben; der Client wechselt lediglich auf die LM-Studio-URL. + +### llama.cpp (Server-Modus) + +llama.cpp mit `--jinja` unterstützt Function Calling, ruft aber nicht selbst HTTP-Endpoints +auf. Benötigt eine Middleware (z. B. das Ollama-Beispiel oben), die generierte Tool-Calls +abfängt und an `/speak` weiterleitet. + +### Home Assistant + +```yaml +# configuration.yaml +rest_command: + tts_speak: + url: "http://192.168.x.x:9999/speak" + method: POST + content_type: "application/json" + payload: '{"text": "{{ text }}", "lang": "de"}' + + tts_stop: + url: "http://192.168.x.x:9999/stop" + method: POST +``` + +Aufruf in einer Automation: +```yaml +service: rest_command.tts_speak +data: + text: "Die Waschmaschine ist fertig." +``` + +### Node-RED / n8n + +HTTP-Request-Node direkt auf `POST http://:9999/speak` mit JSON-Body. +Kein weiterer Setup nötig. + +### Pi (Inflection AI) + +Keine Tool-API verfügbar — direkte Integration nicht möglich. + +--- ## Aussprache-Wörterbuch -Für Eigennamen und Fremdwörter, die das Modell falsch ausspricht: - ```json { "Xi Jinping": "Schi Dschinping", @@ -124,16 +329,16 @@ python chatterbox_cli_v4.py --lang de \ --input nachricht.txt ``` +--- + ## Bekannte Einschränkungen -- **Wortbetonung** lässt sich nicht steuern — das Modell kennt kein SSML. - Abhilfe: Voice-Referenz mit gewünschter Betonung aufnehmen. -- **Chinesische/japanische Namen** werden phonetisch angenähert; das Modell - ist nicht für asiatische Phonetik optimiert. -- **Sehr lange Texte** werden satzweise verarbeitet; zwischen Absätzen können - kurze Pausen entstehen (Generierungszeit für den nächsten Satz). +- **Wortbetonung** lässt sich nicht steuern — kein SSML. Abhilfe: Voice-Referenz mit gewünschter Betonung. +- **Laufendes `model.generate()`** kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen); Stop greift am nächsten Chunk-Beginn. +- **Chinesische/japanische Namen** werden phonetisch angenähert. + +--- ## Lizenz -MIT — dieses Skript. Das Chatterbox-Modell unterliegt der MIT-Lizenz von -Resemble AI. Die Modellgewichte sind nicht-kommerziell (CC BY-NC 4.0). +MIT — dieses Skript. Das Chatterbox-Modell: MIT-Lizenz (Resemble AI). Modellgewichte: CC BY-NC 4.0. diff --git a/chatterbox_cli_v4.py b/chatterbox_cli_v4.py index 4ffc32d..ed997fa 100755 --- a/chatterbox_cli_v4.py +++ b/chatterbox_cli_v4.py @@ -463,7 +463,9 @@ def force_split_sentence(text: str, max_len: int) -> List[str]: while len(remaining) > max_len: split_pos = remaining.rfind(" ", 0, max_len + 1) if split_pos <= 0: - split_pos = max_len + # Kein Leerzeichen vor max_len — vorwärts zum nächsten Wortende suchen + next_space = remaining.find(" ", max_len) + split_pos = next_space if next_space != -1 else len(remaining) parts.append(remaining[:split_pos].strip()) remaining = remaining[split_pos:].strip() @@ -830,15 +832,9 @@ def synthesize_streaming( if voice_path and not Path(voice_path).exists(): raise FileNotFoundError(f"Voice-Referenz nicht gefunden: {voice_path}") - text = preprocess_tts_text( - text=text, - lang=lang, - spell_uppercase_acronyms=spell_uppercase_acronyms, - acronym_mode=acronym_mode, - normalize_time_values=normalize_time_values, - normalize_year_values=normalize_year_values, - normalize_units_values=normalize_units_values, - ) + # Erst bereinigen und splitten, dann pro Chunk normalisieren — + # sonst erzeugen Akronym-Punkte ("ARD" → "Ah Er De.") falsche Satzgrenzen. + text = clean_raw_text(text) model, model_kind, sr = load_model(lang, device) @@ -849,9 +845,20 @@ def synthesize_streaming( ) if conversation_mode: - text_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len) + raw_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len) else: - text_chunks = split_long_text(text, max_len=max_len) + raw_chunks = split_long_text(text, max_len=max_len) + + preprocess_kw = dict( + lang=lang, + spell_uppercase_acronyms=spell_uppercase_acronyms, + acronym_mode=acronym_mode, + normalize_time_values=normalize_time_values, + normalize_year_values=normalize_year_values, + normalize_units_values=normalize_units_values, + ) + text_chunks = [preprocess_tts_text(c, **preprocess_kw) for c in raw_chunks] + text_chunks = [c for c in text_chunks if c.strip()] if not text_chunks: raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.") diff --git a/mcp_adapter.py b/mcp_adapter.py index ca1562e..804d104 100644 --- a/mcp_adapter.py +++ b/mcp_adapter.py @@ -2,7 +2,7 @@ """ Chatterbox TTS – MCP-Adapter -Setzt einen laufenden tts_service.py voraus (Standard: http://127.0.0.1:8000). +Setzt einen laufenden tts_service.py voraus (Standard: http://127.0.0.1:9999). Start (streamable-http, Port 8001 – für beliebige MCP-Clients): python mcp_adapter.py @@ -10,18 +10,13 @@ Start (streamable-http, Port 8001 – für beliebige MCP-Clients): Start (stdio – für Claude Code / Claude Desktop): python mcp_adapter.py --stdio -Claude Code Konfiguration (.claude/settings.json): - { - "mcpServers": { - "chatterbox-tts": { - "command": "python", - "args": ["/home/dschlueter/chatterbox-tts-cli/mcp_adapter.py", "--stdio"] - } - } - } +Claude Code (bereits konfiguriert via `claude mcp add --scope user`): + claude mcp add --scope user chatterbox-tts \ + /home/dschlueter/miniforge3/envs/chatterbox/bin/python \ + /home/dschlueter/chatterbox-tts-cli/mcp_adapter.py --stdio Umgebungsvariable TTS_URL überschreibt die Service-Adresse: - TTS_URL=http://192.168.1.10:8000 python mcp_adapter.py --stdio + TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio """ from __future__ import annotations @@ -31,7 +26,7 @@ import os import httpx from mcp.server.fastmcp import FastMCP -TTS_URL = os.environ.get("TTS_URL", "http://127.0.0.1:8000").rstrip("/") +TTS_URL = os.environ.get("TTS_URL", "http://127.0.0.1:9999").rstrip("/") mcp = FastMCP( "Chatterbox TTS", diff --git a/requirements.txt b/requirements.txt index 3b709db..02c156b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,27 +1,25 @@ # Chatterbox TTS CLI — Abhängigkeiten # Getestet mit Python 3.11, CUDA 12.x, Ubuntu 22.04/24.04 +# +# PyTorch separat installieren (passende CUDA-Version via pytorch.org): +# pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu124 -# TTS-Kern +# --- TTS-Kern --- chatterbox-tts>=0.1.7 -# PyTorch (passende CUDA-Version separat installieren, z. B. via pytorch.org) -torch>=2.6.0 -torchaudio>=2.6.0 - -# Audio-Ausgabe (Linux/PipeWire/PulseAudio) +# --- Audio-Ausgabe (Linux/PipeWire/PulseAudio) --- sounddevice>=0.4.0 -# Pitch-erhaltende Zeitstreckung (Geschwindigkeitsanpassung) +# --- Pitch-erhaltende Zeitstreckung (--speed != 1.0) --- +# Systempaket zusätzlich erforderlich: sudo apt install rubberband-cli pyrubberband>=0.4.0 -# rubberband-cli muss zusätzlich als Systempakete installiert sein: -# sudo apt install rubberband-cli -# HTTP-Service (Phase 2) +# --- HTTP-Service (tts_service.py) --- fastapi>=0.115.0 uvicorn[standard]>=0.32.0 -# HTTP-Client für MCP-Adapter (Phase 3) +# --- HTTP-Client (mcp_adapter.py → tts_service.py) --- httpx>=0.28.0 -# MCP-Adapter (Phase 3) +# --- MCP-Adapter (mcp_adapter.py) --- mcp>=1.0.0 diff --git a/tts_service.py b/tts_service.py index 0b2a6a5..00ecbbd 100644 --- a/tts_service.py +++ b/tts_service.py @@ -3,7 +3,7 @@ Chatterbox TTS – lokaler HTTP-Service Start: - uvicorn tts_service:app --host 127.0.0.1 --port 8000 + uvicorn tts_service:app --host 0.0.0.0 --port 9999 Endpunkte: POST /speak – Text in Warteschlange einreihen @@ -178,8 +178,8 @@ class SpeakRequest(BaseModel): interrupt: bool = False speed: float = Field(default=1.0, ge=0.5, le=2.0) t3_model: str = "v3" - audio_device: str = "pulse" - max_len: int = Field(default=400, ge=50, le=1000) + audio_device: Optional[str] = None + max_len: int = Field(default=400, ge=100, le=1000) save_wav: bool = False output_path: Optional[str] = None session_id: Optional[str] = None From 34a34907a8c87d641fa217802168928b464e41b0 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 3 Jun 2026 11:36:54 +0200 Subject: [PATCH 4/4] Bugfixes, Verbesserungen und Mixed-Language-Support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugfixes: - Abkürzungen (z.B., d.h., Dr., Prof.) werden nicht mehr als Satzenden erkannt (_ABBREV_MASK_RE) - Multilingual-Import: except Exception → except (ImportError, ModuleNotFoundError) - tts_agent: ReAct-Schleife auf max. 10 Iterationen begrenzt, model_dump → explizites Dict - tts_service: audio_device=None fällt auf 'pulse' zurück - JSON-Fehlerbehandlung für --pronunciation-dict mit aussagekräftiger Meldung - PlaybackWorker: Audio-Device wird vor Stream-Start via sd.query_devices() geprüft - mcp_adapter: Fehlerbehandlung für HTTP-Fehler, Timeout erhöht, session_id ergänzt - tts_agent: Health-Check beim Start, --speed/--first-chunk-len Validierung Neue Features: - Gemischtsprachige Texte: [en]...[/en]-Markierungen für per-Segment language_id - strip_markdown(): entfernt Markdown-Formatierung vor der Synthese (--no-strip-markdown) - Emoji-Entfernung in clean_raw_text() via unicodedata - Pause/Resume: request_pause()/request_resume(), POST /pause, POST /resume, MCP-Tools - Neue Einheiten: °C, °F, kWh, kW, W, V, A, J, kPa, bar, m², m³, m/s, rpm - number_to_words_de/en bis Milliarden - DEFAULT_PRONUNCIATION_DE erweitert (GitHub, YouTube, LinkedIn, Wi-Fi, iPhone, ChatGPT, …) - NON_SPELLED_ACRONYMS erweitert (USB, CPU, GPU, API, CEO, HTML, …) - Nummerierte Listen als separate Chunks behandelt - Modell-Warmup via TTS_PRELOAD_LANG Env-Variable - requirements.txt: Upper-Bounds für fastapi und uvicorn Dokumentation: CLAUDE.md, README.md, BEDIENUNGSANLEITUNG.md vollständig aktualisiert Co-Authored-By: Claude Sonnet 4.6 --- BEDIENUNGSANLEITUNG.md | 44 ++++-- CLAUDE.md | 98 +++++++++++--- README.md | 124 +++++++++++++---- chatterbox_cli_v4.py | 294 +++++++++++++++++++++++++++++++++++------ mcp_adapter.py | 61 ++++++--- requirements.txt | 8 +- tts_agent.py | 237 +++++++++++++++++++++++++++++++++ tts_service.py | 26 +++- 8 files changed, 778 insertions(+), 114 deletions(-) create mode 100644 tts_agent.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index e1c131d..32a63c9 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -152,9 +152,9 @@ Du kannst das mit einer einfachen Textdatei im JSON-Format korrigieren. ```json { - "Xi Jinping": "Schi Dschinping", "Seoul": "Söul", - "Macron": "Makron" + "Macron": "Makron", + "Kubernetes": "Kubernetis" } ``` @@ -166,6 +166,9 @@ python chatterbox_cli_v4.py --lang de \ --input nachricht.txt ``` +Häufige englische Begriffe wie „GitHub" (→ „Git Hab"), „YouTube" (→ „Jutjub") oder +„Wi-Fi" (→ „Wai Fai") werden bereits automatisch korrekt ausgesprochen. + --- ## Den Service aus dem Netzwerk nutzen @@ -188,6 +191,12 @@ curl -X POST http://COMPUTER-IP:9999/speak \ hostname -I ``` +**Ausgabe pausieren und fortsetzen:** +```bash +curl -X POST http://COMPUTER-IP:9999/pause +curl -X POST http://COMPUTER-IP:9999/resume +``` + **Ausgabe stoppen:** ```bash curl -X POST http://COMPUTER-IP:9999/stop @@ -203,10 +212,12 @@ kann er den TTS-Service direkt ansprechen: ### Claude (Claude Code / Claude Desktop) Claude ist bereits mit dem TTS-Service verbunden. Du kannst Claude einfach bitten, -etwas vorzulesen — er ruft den Service automatisch auf. +etwas vorzulesen, zu pausieren oder zu stoppen — er ruft den Service automatisch auf. -Beispiel-Anfrage an Claude: +Beispiel-Anfragen an Claude: > „Lies mir bitte diesen Text vor: ..." +> „Pause bitte." +> „Weiter." ### Home Assistant @@ -219,6 +230,14 @@ rest_command: method: POST content_type: "application/json" payload: '{"text": "{{ text }}", "lang": "de"}' + + tts_pause: + url: "http://COMPUTER-IP:9999/pause" + method: POST + + tts_weiter: + url: "http://COMPUTER-IP:9999/resume" + method: POST ``` Danach in einer Automation verwendbar: @@ -249,11 +268,15 @@ Service verbunden werden — Details in der `README.md`. ## Was das Programm automatisch macht -- Abkürzungen buchstabieren: ARD wird zu „Ah Er De", YMCA zu „Ypsilon Em Tse Ah" -- Zusammengesetzte Wörter mit Abkürzung: „US-Präsident" wird zu „U Es Präsident" -- Uhrzeiten vorlesen: „14:58" wird zu „vierzehn Uhr achtundfünfzig" -- Jahreszahlen aussprechen: „2026" wird zu „zweitausendsechsundzwanzig" -- Trennzeilen wie „--- Ende ---" werden stillschweigend übersprungen +- **Markdown-Formatierung bereinigen**: `**fett**`, `# Überschrift`, `- Listen` und Links werden vor der Sprachausgabe entfernt +- **Emojis entfernen**: Smileys und Symbole (😊, 🎉) werden still übergangen +- **Abkürzungen buchstabieren**: ARD wird zu „Ah Er De", YMCA zu „Ypsilon Em Tse Ah" +- **Tech-Abkürzungen richtig sprechen**: CPU, GPU, USB, API, JSON u. a. werden lateinisch buchstabiert (nicht auf Deutsch) +- **Zusammengesetzte Wörter mit Abkürzung**: „US-Präsident" wird zu „U Es Präsident" +- **Uhrzeiten vorlesen**: „14:58" wird zu „vierzehn Uhr achtundfünfzig" +- **Jahreszahlen aussprechen**: „2026" wird zu „zweitausendsechsundzwanzig" +- **Einheiten übersetzen**: „25 °C", „100 kWh", „10 m²", „100 W" werden ausgeschrieben +- **Trennzeilen überspringen**: Linien wie „--- Ende ---" werden stillschweigend übersprungen --- @@ -286,8 +309,7 @@ kann 30–60 Sekunden brauchen. Mit GPU (CUDA) dauert es ca. 5–10 Sekunden. - **Betonung einzelner Wörter** lässt sich nicht direkt steuern. Eine Aufnahme der eigenen Stimme mit natürlicher Betonung kann helfen. -- **Manche Fremdwörter** (z. B. chinesische oder arabische Namen) klingen - nicht immer perfekt — mit der Aussprache-Datei lässt sich das korrigieren. +- **Manche Fremdwörter** klingen nicht immer perfekt — mit der Aussprache-Datei lässt sich das korrigieren. - Das Programm liest alles vor, was in der Datei steht — also auch Überschriften und Metadaten wie „Schlagzeile:" oder „Stand:". - Eine laufende Ausgabe kann erst am Ende des aktuellen Satzes unterbrochen diff --git a/CLAUDE.md b/CLAUDE.md index 6f0a870..6689d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,12 @@ python chatterbox_cli_v4.py --lang de --stream --input text.txt # Aussprache-Wörterbuch (JSON: {"Eigenname": "Lautschrift"}) python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --input text.txt + +# Markdown-Bereinigung deaktivieren (Standard: aktiv) +python chatterbox_cli_v4.py --lang de --no-strip-markdown --input text.txt + +# Gemischtsprachiger Text mit Sprachmarkierungen +python chatterbox_cli_v4.py --lang de --text "Das [en]Machine Learning[/en] Modell ist gut." ``` No build step, no test suite, no linter configuration — this is a single-file script. @@ -42,11 +48,14 @@ journalctl --user -u chatterbox-tts -f # Manuell starten (Port 9999, LAN-weit erreichbar): uvicorn tts_service:app --host 0.0.0.0 --port 9999 +# Mit Modell-Warmup (Modell beim Start laden, kein Cold-Start beim ersten Request): +TTS_PRELOAD_LANG=de uvicorn tts_service:app --host 0.0.0.0 --port 9999 + # Health-Check: curl http://127.0.0.1:9999/health ``` -Endpunkte: `POST /speak`, `POST /stop`, `GET /health`, `GET /status`, `GET /voices` +Endpunkte: `POST /speak`, `POST /stop`, `POST /pause`, `POST /resume`, `GET /health`, `GET /status`, `GET /voices` ## Running the MCP Adapter @@ -61,6 +70,8 @@ python mcp_adapter.py TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio ``` +MCP-Tools: `speak`, `stop`, `pause`, `resume`, `get_status`, `list_voices` + ## Architecture ### Files @@ -70,33 +81,83 @@ TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio | `chatterbox_cli_v4.py` | Kern-CLI und alle Hilfsfunktionen; wird von `tts_service.py` importiert | | `tts_service.py` | FastAPI-Service mit Job-Queue und Worker-Thread | | `mcp_adapter.py` | MCP-Wrapper über die REST-API | +| `tts_agent.py` | Eigenständiger Konversationsagent (Ollama/OpenAI-kompatibel, max. 10 ReAct-Iterationen) | ### CLI pipeline (`chatterbox_cli_v4.py`) -**Text input → `clean_raw_text` → chunking → `preprocess_tts_text` per chunk → TTS generation → audio output** +``` +Text input + → strip_markdown() (Markdown-Syntax entfernen, opt-out via --no-strip-markdown) + → clean_raw_text() (unsichtbare Zeichen + Emojis entfernen) + → extract_language_spans() ([en]...[/en]-Markierungen → [(text, lang), ...]) + → split_into_sentences() (pro Span; Abkürzungen + nummerierte Listen korrekt behandelt) + → preprocess_tts_text() (pro Chunk: Pronunciation-Dict → Einheiten → Zeiten → Jahre → Akronyme) + → generate_chunk() (TTS mit span-spezifischer language_id) + → PlaybackWorker (Audio-Ausgabe) +``` Reihenfolge ist kritisch: erst splitten (Satzgrenzen auf Rohtext erkennen), dann normalisieren (Akronym-Punkte würden sonst falsche Satzgrenzen erzeugen). -### Stop/Interrupt +### Stop/Interrupt/Pause -Modul-globales `threading.Event`: +Zwei modul-globale `threading.Event`-Objekte: ```python -STOP_REQUESTED = threading.Event() -request_stop() # setzt das Event -clear_stop() # löscht es vor jedem neuen Job -stop_requested() # abfragen +STOP_REQUESTED = threading.Event() +PAUSE_REQUESTED = threading.Event() + +request_stop() # setzt STOP_REQUESTED, löscht PAUSE_REQUESTED +clear_stop() # löscht STOP_REQUESTED (vor neuem Job) +stop_requested() # abfragen + +request_pause() # setzt PAUSE_REQUESTED +request_resume() # löscht PAUSE_REQUESTED +is_paused() # abfragen ``` -`PlaybackWorker` und beide Synthesize-Funktionen prüfen das Event an Chunk-Grenzen. Ein laufendes `model.generate()` kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen) — der Abbruch greift am nächsten Chunk. +`PlaybackWorker._callback()` und beide Synthesize-Funktionen prüfen beide Events an Chunk-Grenzen. Ein laufendes `model.generate()` kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen) — Stop/Pause greifen am nächsten Chunk. Pause hält Audio stumm und blockiert die Chunk-Schleife; Resume setzt sie fort ohne Datenverlust. + +### Text preprocessing + +#### `strip_markdown(text)` +Entfernt Markdown-Formatierung vor allen weiteren Schritten: +- `**fett**` / `*kursiv*` → Inhalt +- `` `code` `` / ```` ```block``` ```` → Inhalt / entfernt +- `# Überschrift` → Überschrift +- `- Listenpunkt` / `> Blockquote` → Inhalt +- `[Link](URL)` → Link-Text +- `---` (horizontale Linien) → entfernt + +Default: aktiv. Deaktivierbar via `--no-strip-markdown`. + +#### `clean_raw_text(text)` +- Entfernt unsichtbare Unicode-Zeichen (ZWSP, ZWNJ, BOM, …) +- Entfernt Emojis und nicht-druckbare Sondersymbole (`unicodedata.category` ∈ `{So, Cn, Co}`) + +#### `extract_language_spans(text, default_lang)` +Zerlegt Text mit `[xx]...[/xx]`-Markierungen in `[(segment, lang), ...]`-Tupel: +``` +"Das [en]Machine Learning[/en] Modell." +→ [("Das", "de"), ("Machine Learning", "en"), ("Modell.", "de")] +``` +Ohne Markierungen: `[(text, default_lang)]` — identisches Verhalten wie bisher. +Jedes Segment wird mit der richtigen `language_id` an `generate_chunk()` übergeben. ### Text normalization (`preprocess_tts_text`) 1. Pronunciation dict (vor Akronym-Expansion, damit Eigennamen zuerst greifen) -2. Unit normalization (120 km/h → "120 Kilometer pro Stunde") +2. Unit normalization (120 km/h → "120 Kilometer pro Stunde"; auch: °C, °F, W, V, A, kWh, m², m³, m/s, …) 3. Time normalization (14:58 → "vierzehn Uhr achtundfünfzig") -4. Year normalization (2026 → "zweitausendsechsundzwanzig") +4. Year normalization (2026 → "zweitausendsechsundzwanzig"; bis Billionen) 5. Acronym spelling (ARD → "Ah Er De"; `NON_SPELLED_ACRONYMS` ausgenommen) -`DEFAULT_PRONUNCIATION_DE` enthält eingebaute deutsche Lautschrift-Näherungen (z. B. Xi → "Schi"). +`DEFAULT_PRONUNCIATION_DE` enthält eingebaute phonetische Näherungen: +- Chinesische Namen: Xi → "Schi", Xi Jinping → "Schi Jinping" +- Tech-Marken: GitHub → "Git Hab", YouTube → "Jutjub", Wi-Fi → "Wai Fai", iPhone → "Aiphone", LinkedIn → "Linked In" +- KI-Begriffe: ChatGPT, OpenAI, GPT, LLM + +`NON_SPELLED_ACRONYMS` (werden NICHT in deutsche Buchstabennamen umgewandelt): +- Internationale Org.: NATO, NASA, UNESCO, OPEC, IAEA, UNICEF +- Tech-Abkürzungen: USB, SSD, RAM, CPU, GPU, URL, API, PDF, LAN, WLAN, HTML, HTTP, HTTPS, JSON, SQL, VPN, SSH, FTP +- Titel: CEO, CFO, CTO, COO ### Text chunking @@ -105,31 +166,38 @@ Drei Modi (CLI-Flags): - **conversation_mode**: `split_for_conversation()` — erster Chunk klein (`--first-chunk-len`, default 80), Rest bis `--len` (400) - **plain**: `split_long_text()` — absatzbasiertes Chunking bis `--len` -`force_split_sentence` sucht bei Überlänge erst vorwärts zum nächsten Wortende — kein Schneiden mitten im Wort. +`split_into_sentences()` behandelt: +- **Abkürzungen** (`_ABBREV_MASK_RE`): z.B., d.h., Dr., Prof., Nr., etc. werden nicht als Satzenden erkannt +- **Nummerierte Listen**: `"1. Punkt\n2. Punkt"` → jeder Listenpunkt wird als eigener Chunk behandelt +- **Überlange Sätze**: `force_split_sentence` sucht bei Überlänge vorwärts zum nächsten Wortende ### Model loading (`load_model`) - `--lang en` → `ChatterboxTTS` (mono, immer verfügbar) -- Andere Sprachen → `ChatterboxMultilingualTTS` (`HAS_MULTILINGUAL`-Flag bewacht Import) +- Andere Sprachen → `ChatterboxMultilingualTTS` (`HAS_MULTILINGUAL`-Flag bewacht Import; `except (ImportError, ModuleNotFoundError)`) - `--t3-model v3` (default) oder `v2` wählt den multilingualen T3-Checkpoint - Modelle werden in `~/.cache/huggingface/` gecacht (~2–3 GB) - **Kritisch**: `attn_implementation = "eager"` wird beim Import erzwungen — SDPA gibt `None`-Attention-Weights zurück und bricht den `AlignmentStreamAnalyzer`-Hook ### Audio output (`PlaybackWorker`) +- Vor Stream-Start: `sd.query_devices(self.device)` prüft Gerät-Existenz frühzeitig - `sounddevice.OutputStream` mit Callback bei 48 kHz (PipeWire/PulseAudio-Standard) - Interner Producer-Thread: Torch-Tensoren → `CALLBACK_BLOCK`-große (2048 Samples) numpy-Arrays - `--speed != 1.0`: pyrubberband R3-Engine (`--fine`) streckt Zeit ohne Pitch-Änderung, dann Resampling via `torchaudio.functional.resample(chunk, model_sr, 48000)` - `PlaybackWorker.stop()` schickt `None`-Sentinel in die Queue und jointed den Thread +- Bei `PAUSE_REQUESTED`: Callback gibt Stille aus, Chunk-Schleife wartet ### Two synthesis paths -- **`synthesize_non_streaming`**: generiert jeden Chunk vollständig, füttert fertige Tensoren in `PlaybackWorker`, concateniert alle WAVs für `--save` +- **`synthesize_non_streaming`**: generiert jeden Chunk vollständig, füttert fertige Tensoren in `PlaybackWorker`, concateniert alle WAVs für `--save`; unterstützt `[en]...[/en]`-Sprachmarkierungen pro Chunk - **`synthesize_streaming`**: ruft `model.generate_stream()` mit `chunk_size` auf; jeder Audio-Sub-Chunk geht direkt in `PlaybackWorker`; experimentell ### HTTP Service (`tts_service.py`) - **Modell-Cache**: `_model_cache: dict[(lang, t3_model), (model, kind, sr)]` — einmal laden, halten; Thread-sicher via `_model_lock` +- **Modell-Warmup**: `TTS_PRELOAD_LANG=de` lädt das Modell beim Service-Start (kein Cold-Start-Delay beim ersten Request) - **Job-Queue**: `queue.Queue[SpeakJob]` mit einzelnem Worker-Thread; verhindert parallelen GPU/Audio-Zugriff - **`SpeakRequest.interrupt`**: ruft `request_stop()` + `_drain_queue()` vor dem Einreihen auf +- **Pause/Resume**: `POST /pause` → `request_pause()`, `POST /resume` → `request_resume()`; ohne Datenverlust, Job bleibt in Queue - **Status**: `_current_job`, `_recent_jobs` (max. 20) via `_state_lock` thread-safe lesbar diff --git a/README.md b/README.md index fb5fd3a..03a9787 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,14 @@ HTTP-Service und als MCP-Server für KI-Assistenten. - **Satz-für-Satz-Ausgabe** — gibt den ersten Satz aus, während die nächsten bereits generiert werden; minimale Latenz - **Lückenlose Audiowiedergabe** — Callback-basierter OutputStream; keine Unterbrechungen zwischen Sätzen +- **Pause/Resume** — Ausgabe pausieren und fortsetzen ohne Datenverlust (`POST /pause`, `POST /resume`) - **Geschwindigkeitsanpassung** — pitch-erhaltende Zeitstreckung via pyrubberband (R3-Engine); `--speed 0.5`–`2.0` - **Voice Cloning** — optionale WAV-Referenz für Akzent und Klang - **Mehrsprachig** — Deutsch, Englisch und 20+ weitere Sprachen via `ChatterboxMultilingualTTS` -- **Deutsche Textnormalisierung** — Abkürzungen (ARD → „Ah Er De"), Uhrzeiten (14:58 → „vierzehn Uhr achtundfünfzig"), Jahreszahlen, Einheiten, Aussprache-Wörterbuch -- **HTTP-Service** — FastAPI-Service mit Job-Queue, Stop/Interrupt, Status-Endpunkt +- **Gemischtsprachige Texte** — `[en]...[/en]`-Markierungen für englische Passagen in deutschen Texten +- **Deutsche Textnormalisierung** — Abkürzungen (ARD → „Ah Er De"), Uhrzeiten (14:58 → „vierzehn Uhr achtundfünfzig"), Jahreszahlen bis Milliarden, Einheiten (°C, °F, kWh, m², …), Aussprache-Wörterbuch +- **Markdown-Bereinigung** — entfernt `**fett**`, `# Überschrift`, Links, Code-Blöcke automatisch vor der Synthese +- **HTTP-Service** — FastAPI-Service mit Job-Queue, Stop/Pause/Interrupt, Status-Endpunkt - **MCP-Adapter** — direkte Integration in Claude Code, Claude Desktop und andere MCP-Hosts - **Systemd-Autostart** — Service startet automatisch beim Login @@ -71,6 +74,13 @@ python chatterbox_cli_v4.py --lang de --no-play --output ausgabe.wav --input tex # Aussprache-Wörterbuch python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --input text.txt + +# Gemischtsprachiger Text +python chatterbox_cli_v4.py --lang de --text \ + "Das [en]Machine Learning[/en] Modell kostet ca. 50 €." + +# Markdown-Bereinigung deaktivieren +python chatterbox_cli_v4.py --lang de --no-strip-markdown --input text.txt ``` ### CLI-Optionen @@ -86,6 +96,7 @@ python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --inp | `--t3-model` | `v3` | Multilingual-Modell: `v3` oder `v2` | | `--acronym-mode` | `german` | Akronym-Modus: `german`, `space`, `period_space` | | `--pronunciation-dict` | — | JSON-Datei mit Aussprache-Substitutionen | +| `--no-strip-markdown` | — | Markdown-Formatierung nicht entfernen | | `--save` | nein | WAV-Datei speichern | | `--output DATEI.wav` | — | Ausgabepfad (impliziert `--save`) | | `--no-play` | — | Nicht live abspielen | @@ -97,6 +108,33 @@ python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --inp --- +## Gemischtsprachige Texte + +Deutsche Texte enthalten oft englische Fachbegriffe, Markennamen oder Zitate. +Mit `[xx]...[/xx]`-Markierungen werden diese Passagen mit der richtigen `language_id` +an das Multilingual-Modell übergeben: + +``` +Das [en]Machine Learning[/en] Framework kostet ca. 50 €. +Der [en]CEO[/en] sagte: [en]"We are committed to innovation."[/en] +``` + +Ohne Markierungen verhält sich das System identisch wie bisher. + +Häufige englische Tech-Begriffe werden bereits automatisch korrekt ausgesprochen +(eingebaut in `DEFAULT_PRONUNCIATION_DE`): + +| Begriff | Aussprache | +|---------|-----------| +| GitHub | „Git Hab" | +| YouTube | „Jutjub" | +| LinkedIn | „Linked In" | +| Wi-Fi | „Wai Fai" | +| iPhone | „Aiphone" | +| ChatGPT | „Tschet Dschie Pie Tie" | + +--- + ## HTTP-Service (`tts_service.py`) FastAPI-Service mit Job-Queue und Worker-Thread. Startet automatisch via systemd. @@ -105,6 +143,9 @@ FastAPI-Service mit Job-Queue und Worker-Thread. Startet automatisch via systemd # Manueller Start uvicorn tts_service:app --host 0.0.0.0 --port 9999 +# Mit Modell-Warmup (kein Cold-Start beim ersten Request) +TTS_PRELOAD_LANG=de uvicorn tts_service:app --host 0.0.0.0 --port 9999 + # Systemd (Autostart, läuft bereits) systemctl --user status chatterbox-tts systemctl --user restart chatterbox-tts @@ -117,6 +158,8 @@ journalctl --user -u chatterbox-tts -f |---------|------|----------| | `POST` | `/speak` | Text in Queue einreihen | | `POST` | `/stop` | Ausgabe abbrechen, Queue leeren | +| `POST` | `/pause` | Ausgabe pausieren (ohne Datenverlust) | +| `POST` | `/resume` | Pausierte Ausgabe fortsetzen | | `GET` | `/health` | Service-Status und Gerät | | `GET` | `/status` | Aktueller Job, Queue-Länge, letzte Jobs | | `GET` | `/voices` | Unterstützte Sprachen | @@ -135,7 +178,8 @@ journalctl --user -u chatterbox-tts -f "max_len": 400, "save_wav": false, "output_path": null, - "pronunciation_dict": null + "pronunciation_dict": null, + "session_id": null } ``` @@ -152,8 +196,12 @@ curl -X POST http://192.168.x.x:9999/speak \ # Laufende Ausgabe unterbrechen curl -X POST http://localhost:9999/speak \ - -d '{"text": "Wichtiger Text", "lang": "de", "interrupt": true}' \ - -H "Content-Type: application/json" + -H "Content-Type: application/json" \ + -d '{"text": "Wichtiger Text", "lang": "de", "interrupt": true}' + +# Pausieren und fortsetzen +curl -X POST http://localhost:9999/pause +curl -X POST http://localhost:9999/resume # Stoppen curl -X POST http://localhost:9999/stop @@ -180,10 +228,12 @@ TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio | Tool | Parameter | Funktion | |------|-----------|----------| -| `speak` | text, lang, voice, interrupt, speed | Text ausgeben | -| `stop` | — | Ausgabe stoppen | -| `get_status` | — | Aktuellen Job abfragen | -| `list_voices` | — | Sprachen auflisten | +| `speak` | text, lang, voice, interrupt, speed, session_id | Text ausgeben | +| `stop` | — | Ausgabe stoppen und Queue leeren | +| `pause` | — | Ausgabe pausieren (ohne Datenverlust) | +| `resume` | — | Pausierte Ausgabe fortsetzen | +| `get_status` | — | Aktuellen Job und Queue abfragen | +| `list_voices` | — | Unterstützte Sprachen auflisten | ### Claude Code Konfiguration @@ -214,7 +264,7 @@ claude mcp add --scope user chatterbox-tts \ ### Claude Code / Claude Desktop — MCP (fertig eingerichtet) -Claude kann direkt die Tools `speak`, `stop`, `get_status` und `list_voices` aufrufen. +Claude kann direkt die Tools `speak`, `stop`, `pause`, `resume`, `get_status` und `list_voices` aufrufen. Kein weiterer Setup nötig. ### Ollama (llama3.2, qwen2.5, mistral-nemo u. a.) @@ -248,6 +298,24 @@ for call in resp.message.tool_calls or []: httpx.post("http://127.0.0.1:9999/speak", json=call.function.arguments) ``` +### TTS Agent (`tts_agent.py`) + +Eigenständiger Konversationsagent mit eingebautem Function Calling: + +```bash +# Mit Ollama +python tts_agent.py --model qwen2.5 + +# Mit LM Studio +python tts_agent.py --base-url http://localhost:1234/v1 --model local-model + +# Mit OpenAI +OPENAI_API_KEY=sk-... python tts_agent.py --model gpt-4o + +# Mit Voice Cloning +python tts_agent.py --model qwen2.5 --voice my_voice.wav +``` + ### Open WebUI Im Open-WebUI-Menü unter *Tools* eine neue Python-Klasse anlegen: @@ -268,17 +336,6 @@ class Tools: return "stopped" ``` -### LM Studio - -LM Studio bietet einen OpenAI-kompatiblen Endpunkt. Die Tool-Definition entspricht dem -Ollama-Beispiel oben; der Client wechselt lediglich auf die LM-Studio-URL. - -### llama.cpp (Server-Modus) - -llama.cpp mit `--jinja` unterstützt Function Calling, ruft aber nicht selbst HTTP-Endpoints -auf. Benötigt eine Middleware (z. B. das Ollama-Beispiel oben), die generierte Tool-Calls -abfängt und an `/speak` weiterleitet. - ### Home Assistant ```yaml @@ -293,6 +350,14 @@ rest_command: tts_stop: url: "http://192.168.x.x:9999/stop" method: POST + + tts_pause: + url: "http://192.168.x.x:9999/pause" + method: POST + + tts_resume: + url: "http://192.168.x.x:9999/resume" + method: POST ``` Aufruf in einer Automation: @@ -307,19 +372,18 @@ data: HTTP-Request-Node direkt auf `POST http://:9999/speak` mit JSON-Body. Kein weiterer Setup nötig. -### Pi (Inflection AI) - -Keine Tool-API verfügbar — direkte Integration nicht möglich. - --- ## Aussprache-Wörterbuch +Für Namen oder Begriffe, die das Modell falsch ausspricht: + ```json { "Xi Jinping": "Schi Dschinping", "Putin": "Pjutin", - "Seoul": "Söul" + "Seoul": "Söul", + "Kubernetes": "Kubernetis" } ``` @@ -329,13 +393,17 @@ python chatterbox_cli_v4.py --lang de \ --input nachricht.txt ``` +Häufige Begriffe sind bereits eingebaut (GitHub, YouTube, iPhone, Xi Jinping u. a.). +Das eigene Dict wird immer **nach** dem eingebauten angewendet — Überschreibungen sind möglich. + --- ## Bekannte Einschränkungen - **Wortbetonung** lässt sich nicht steuern — kein SSML. Abhilfe: Voice-Referenz mit gewünschter Betonung. -- **Laufendes `model.generate()`** kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen); Stop greift am nächsten Chunk-Beginn. -- **Chinesische/japanische Namen** werden phonetisch angenähert. +- **Laufendes `model.generate()`** kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen); Stop/Pause greift am nächsten Chunk-Beginn. +- **Sprachmarkierungen `[en]...[/en]`** funktionieren nur mit `ChatterboxMultilingualTTS`; bei `--lang en` (mono) werden sie ignoriert. +- **Streaming-Modus** unterstützt keine Sprachmarkierungen. --- diff --git a/chatterbox_cli_v4.py b/chatterbox_cli_v4.py index ed997fa..fef7e10 100755 --- a/chatterbox_cli_v4.py +++ b/chatterbox_cli_v4.py @@ -11,19 +11,45 @@ from pathlib import Path from typing import List, Optional, Tuple # --------------------------------------------------------------------------- -# Kooperativer Stop-Mechanismus +# Kooperativer Stop- und Pause-Mechanismus # --------------------------------------------------------------------------- -STOP_REQUESTED = threading.Event() +STOP_REQUESTED = threading.Event() +PAUSE_REQUESTED = threading.Event() + def request_stop() -> None: STOP_REQUESTED.set() + PAUSE_REQUESTED.clear() # eine laufende Pause beim Stop aufheben + def clear_stop() -> None: STOP_REQUESTED.clear() + def stop_requested() -> bool: return STOP_REQUESTED.is_set() + +def request_pause() -> None: + PAUSE_REQUESTED.set() + + +def request_resume() -> None: + PAUSE_REQUESTED.clear() + + +def is_paused() -> bool: + return PAUSE_REQUESTED.is_set() + + +def _wait_while_paused(stop_event: Optional[threading.Event] = None) -> bool: + """Blockiert solange pausiert ist. Gibt True zurück wenn Stop angefordert wurde.""" + while PAUSE_REQUESTED.is_set(): + if (stop_event and stop_event.is_set()) or STOP_REQUESTED.is_set(): + return True + time.sleep(0.05) + return False + import torch import torchaudio as ta @@ -37,7 +63,7 @@ from chatterbox.tts import ChatterboxTTS try: from chatterbox.mtl_tts import ChatterboxMultilingualTTS HAS_MULTILINGUAL = True -except Exception: +except (ImportError, ModuleNotFoundError): ChatterboxMultilingualTTS = None HAS_MULTILINGUAL = False @@ -58,11 +84,26 @@ SENTENCE_END_RE = re.compile( re.DOTALL ) +# Abkürzungen, deren abschließender Punkt KEIN Satzende ist. +# Punkte in gematchten Mustern werden in split_into_sentences() temporär maskiert. +_ABBREV_MASK_RE = re.compile( + r'\b(?:' + r'z\.B|d\.h|u\.a|z\.T|u\.U|s\.o|s\.u|m\.E|i\.d\.R' # zweiteilige Konnektive + r'|ggf|vgl|etc|ca|usw|bzw|sog|inkl|exkl|bzgl|zzgl' # einsilbige Kürzel + r'|Dr|Prof|Hr|Fr|Hrsg|Dipl|Ing' # Titel + r'|Abs|Nr|Art|Bd|Abb|Kap|Mrd|Mio|Std|Tel|Str' # Fachbegriffe + r')\.' +) + NON_SPELLED_ACRONYMS = { - "NATO", - "NASA", - "UNESCO", - "OPEC", + # Internationale Organisationen / Eigennamen (werden als Wort gesprochen) + "NATO", "NASA", "UNESCO", "OPEC", "IAEA", "UNICEF", + # Tech-Akronyme, die buchstabenweise ausgesprochen werden sollen, + # aber mit deutschen Buchstabennamen falsch klingen würden → daher hier ausnehmen, + # damit sie als lateinische Buchstaben buchstabiert werden (via period_space-Modus) + "USB", "SSD", "RAM", "CPU", "GPU", "URL", "API", "PDF", "LAN", "WLAN", + "HTML", "HTTP", "HTTPS", "JSON", "SQL", "VPN", "SSH", "FTP", + "CEO", "CFO", "CTO", "COO", } GERMAN_LETTER_NAMES = { @@ -92,36 +133,76 @@ TIME_RE = re.compile(r'\b([01]?\d|2[0-3])([:.])([0-5]\d)(?:\s*Uhr)?\b', re.IGNOR # Vierstellige Jahreszahlen YEAR_RE = re.compile(r'\b(19\d{2}|20\d{2}|21\d{2})\b') -# Einfache deutsche Einheiten +# Einfache deutsche Einheiten (absteigende Länge wird in normalize_units() sichergestellt) UNIT_REPLACEMENTS = { + # Geschwindigkeit "km/h": "Kilometer pro Stunde", + "m/s": "Meter pro Sekunde", + "rpm": "Umdrehungen pro Minute", + # Länge "km": "Kilometer", - "m": "Meter", "cm": "Zentimeter", "mm": "Millimeter", + "m": "Meter", + # Fläche / Volumen + "cm²": "Quadratzentimeter", + "m²": "Quadratmeter", + "m³": "Kubikmeter", + # Masse "kg": "Kilogramm", - "g": "Gramm", "mg": "Milligramm", - "Hz": "Hertz", - "kHz": "Kilohertz", - "MHz": "Megahertz", + "g": "Gramm", + # Temperatur + "°C": "Grad Celsius", + "°F": "Grad Fahrenheit", + # Elektrik / Energie + "kWh": "Kilowattstunde", + "kW": "Kilowatt", + "W": "Watt", + "V": "Volt", + "A": "Ampere", + "J": "Joule", + # Frequenz "GHz": "Gigahertz", + "MHz": "Megahertz", + "kHz": "Kilohertz", + "Hz": "Hertz", + # Druck + "kPa": "Kilopascal", + "bar": "bar", + # Datenspeicher + "PB": "Petabyte", + "TB": "Terabyte", + "GB": "Gigabyte", + "Mb": "Megabyte", + "Kb": "Kilobyte", + # Sonstiges "€": "Euro", "$": "Dollar", "%": "Prozent", - "Kb": "Kilobyte", - "Mb": "Megabyte", - "GB": "Gigabyte", - "TB": "Terabyte", - "PB": "Petabyte", } -# Eingebaute phonetische Annäherungen für häufige Fremdnamen (Deutsch) +# Eingebaute phonetische Annäherungen für häufige Fremdnamen und Anglizismen (Deutsch). +# Nur Begriffe aufnehmen, bei denen das deutsche TTS eine falsche Aussprache produziert. +# Anglizismen wie "Cloud", "Update", "Meeting" klingen auf Deutsch akzeptabel → kein Eintrag. DEFAULT_PRONUNCIATION_DE: dict[str, str] = { - "Xi Jinping": "Schi Jinping", - "Xi": "Schi", - "Jinping": "Jinping", - "Peking": "Peking", # bleibt — deutsches TTS kennt es + # Chinesische Eigennamen + "Xi Jinping": "Schi Jinping", + "Xi": "Schi", + "Jinping": "Jinping", + "Peking": "Peking", + # Tech-Markennamen mit problematischer Aussprache + "GitHub": "Git Hab", + "LinkedIn": "Linked In", + "YouTube": "Jutjub", + "Wi-Fi": "Wai Fai", + "iPhone": "Aiphone", + "MacBook": "Mäk Buk", + "ChatGPT": "Tschet Dschie Pie Tie", + "OpenAI": "Open A I", + # KI-Begriffe + "GPT": "Dschie Pie Tie", + "LLM": "El El Em", } @@ -131,13 +212,79 @@ def apply_pronunciation_dict(text: str, pron_dict: dict[str, str]) -> str: return text +import unicodedata as _unicodedata + +# Markdown-Muster für strip_markdown() +_MD_CODE_BLOCK = re.compile(r'```[\s\S]*?```') +_MD_INLINE_CODE = re.compile(r'`([^`\n]+)`') +_MD_BOLD_ITALIC = re.compile(r'[*_]{1,3}([^*_\n]+)[*_]{1,3}') +_MD_HEADING = re.compile(r'^#{1,6}\s+', re.MULTILINE) +_MD_LIST_ITEM = re.compile(r'^\s*[-*+]\s+', re.MULTILINE) +_MD_LINK = re.compile(r'\[([^\]]+)\]\([^\)]+\)') +_MD_IMAGE = re.compile(r'!\[([^\]]*)\]\([^\)]+\)') +_MD_BLOCKQUOTE = re.compile(r'^\s*>\s?', re.MULTILINE) +_MD_HR = re.compile(r'^\s*[-*_]{3,}\s*$', re.MULTILINE) + +# Unicode-Kategorien, die Emojis und nicht druckbare Symbole abdecken +_EMOJI_CATEGORIES = frozenset({"So", "Cn", "Co"}) + + +def strip_markdown(text: str) -> str: + """Entfernt Markdown-Formatierung und gibt lesbaren Klartext zurück.""" + text = _MD_CODE_BLOCK.sub('', text) # ```...``` komplett entfernen + text = _MD_INLINE_CODE.sub(r'\1', text) # `code` → code + text = _MD_IMAGE.sub(r'\1', text) # ![alt](url) → alt + text = _MD_LINK.sub(r'\1', text) # [text](url) → text + text = _MD_BOLD_ITALIC.sub(r'\1', text) # **fett** / *kursiv* → Inhalt + text = _MD_HEADING.sub('', text) # # Überschrift → Überschrift + text = _MD_BLOCKQUOTE.sub('', text) # > Zitat → Zitat + text = _MD_LIST_ITEM.sub('', text) # - Punkt → Punkt + text = _MD_HR.sub('', text) # --- / *** komplett entfernen + return text + + def clean_raw_text(text: str) -> str: - """Unsichtbare Steuerzeichen entfernen, die Splitting oder TTS stoeren.""" + """Unsichtbare Steuerzeichen und Emojis entfernen, die Splitting oder TTS stoeren.""" for ch in ('​', '‌', '‍', ''): text = text.replace(ch, '') + # Emojis und nicht-druckbare Sondersymbole entfernen + text = ''.join( + ch for ch in text + if _unicodedata.category(ch) not in _EMOJI_CATEGORIES + ) return text +# Regex für Sprachmarkierungen [xx]...[/xx] im Text +_LANG_SPAN_RE = re.compile(r'\[([a-z]{2})\](.*?)\[/\1\]', re.DOTALL) + + +def extract_language_spans(text: str, default_lang: str) -> List[Tuple[str, str]]: + """Zerlegt Text mit [xx]...[/xx]-Markierungen in (segment, lang)-Tupel. + + Beispiel: + "Das [en]Machine Learning[/en] Modell." + → [("Das", "de"), ("Machine Learning", "en"), ("Modell.", "de")] + + Ohne Markierungen wird [(text, default_lang)] zurückgegeben. + """ + segments: List[Tuple[str, str]] = [] + last_end = 0 + for m in _LANG_SPAN_RE.finditer(text): + before = text[last_end:m.start()] + if before.strip(): + segments.append((before.strip(), default_lang)) + lang_tag = m.group(1) + content = m.group(2).strip() + if content and lang_tag in SUPPORTED_LANGS: + segments.append((content, lang_tag)) + last_end = m.end() + tail = text[last_end:].strip() + if tail: + segments.append((tail, default_lang)) + return segments if segments else [(text, default_lang)] + + def has_module(name: str) -> bool: return importlib.util.find_spec(name) is not None @@ -197,6 +344,18 @@ def number_to_words_de(n: int) -> str: prefix = "eintausend" if th == 1 else f"{number_to_words_de(th)}tausend" return prefix if r == 0 else f"{prefix}{number_to_words_de(r)}" + if n < 1_000_000_000: + m = n // 1_000_000 + r = n % 1_000_000 + prefix = "eine Million" if m == 1 else f"{number_to_words_de(m)} Millionen" + return prefix if r == 0 else f"{prefix} {number_to_words_de(r)}" + + if n < 1_000_000_000_000: + b = n // 1_000_000_000 + r = n % 1_000_000_000 + prefix = "eine Milliarde" if b == 1 else f"{number_to_words_de(b)} Milliarden" + return prefix if r == 0 else f"{prefix} {number_to_words_de(r)}" + return str(n) @@ -233,6 +392,18 @@ def number_to_words_en(n: int) -> str: prefix = f"{number_to_words_en(th)} thousand" return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}" + if n < 1_000_000_000: + m = n // 1_000_000 + r = n % 1_000_000 + prefix = f"{number_to_words_en(m)} million" + return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}" + + if n < 1_000_000_000_000: + b = n // 1_000_000_000 + r = n % 1_000_000_000 + prefix = f"{number_to_words_en(b)} billion" + return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}" + return str(n) @@ -476,6 +647,10 @@ def force_split_sentence(text: str, max_len: int) -> List[str]: def split_into_sentences(text: str, max_len: int = 200) -> List[str]: + # Nummerierte Listenpunkte ("1. ...", "2. ...") als eigene Absätze normalisieren, + # damit sie nicht mit benachbarten Sätzen zusammengefasst werden. + text = re.sub(r'(?m)^(\d+\.\s+)', r'\n\n\1', text) + result = [] for part in text.split("\n\n"): part = part.strip() @@ -483,13 +658,15 @@ def split_into_sentences(text: str, max_len: int = 200) -> List[str]: continue if SEPARATOR_LINE_RE.match(part): continue - sentences = SENTENCE_END_RE.findall(part) + # Abkürzungspunkte temporär maskieren, damit sie nicht als Satzenden gelten. + masked = _ABBREV_MASK_RE.sub(lambda m: m.group(0)[:-1] + "\x00", part) + sentences = SENTENCE_END_RE.findall(masked) consumed = "".join(sentences).strip() - rest = part[len(consumed):].strip() + rest = masked[len(consumed):].strip() if rest: sentences.append(rest) for sentence in sentences: - sentence = sentence.strip() + sentence = sentence.replace("\x00", ".").strip() if not sentence: continue if len(sentence) > max_len: @@ -592,12 +769,21 @@ class PlaybackWorker: raise RuntimeError( "Für Live-Wiedergabe ist das Modul 'sounddevice' nötig. Installiere z. B. 'pip install sounddevice'." ) + if self.device is not None: + import sounddevice as sd + try: + sd.query_devices(self.device) + except ValueError: + available = [d["name"] for d in sd.query_devices()] + raise RuntimeError( + f"Audio-Gerät nicht gefunden: '{self.device}'. Verfügbare Geräte: {available}" + ) self.thread = threading.Thread(target=self._run, daemon=True) self.thread.start() def _callback(self, outdata, frames, time_info, status): # Läuft im Audio-Thread: so schnell wie möglich, kein Lock nötig. - if self.stop_event and self.stop_event.is_set(): + if (self.stop_event and self.stop_event.is_set()) or PAUSE_REQUESTED.is_set(): outdata[:] = 0.0 return try: @@ -729,15 +915,10 @@ def synthesize_non_streaming( model, model_kind, sr = load_model(lang, device, t3_model=t3_model) - if sentence_mode: - raw_chunks = split_into_sentences(text, max_len=max_len) - elif conversation_mode: - raw_chunks = split_for_conversation(text, first_chunk_len=first_chunk_len, max_len=max_len) - else: - raw_chunks = split_long_text(text, max_len=max_len) + # [xx]...[/xx]-Sprachmarkierungen extrahieren; ohne Markierungen → ein Span in default lang. + lang_spans = extract_language_spans(text, lang) preprocess_kw = dict( - lang=lang, spell_uppercase_acronyms=spell_uppercase_acronyms, acronym_mode=acronym_mode, normalize_time_values=normalize_time_values, @@ -745,12 +926,25 @@ def synthesize_non_streaming( normalize_units_values=normalize_units_values, pronunciation_dict=pronunciation_dict, ) - chunks = [preprocess_tts_text(c, **preprocess_kw) for c in raw_chunks] - chunks = [c for c in chunks if c.strip()] + # chunk_pairs: Liste von (verarbeiteter_Text, chunk_lang) + chunk_pairs: List[Tuple[str, str]] = [] + for span_idx, (span_text, span_lang) in enumerate(lang_spans): + if sentence_mode: + raw = split_into_sentences(span_text, max_len=max_len) + elif conversation_mode and span_idx == 0: + raw = split_for_conversation(span_text, first_chunk_len=first_chunk_len, max_len=max_len) + else: + raw = split_long_text(span_text, max_len=max_len) + for c in raw: + processed = preprocess_tts_text(c, lang=span_lang, **preprocess_kw) + if processed.strip(): + chunk_pairs.append((processed, span_lang)) - if not chunks: + if not chunk_pairs: raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.") + chunks = [t for t, _ in chunk_pairs] # für Progress-Anzeige + if show_progress: print(f"Sprache: {lang}") print(f"Gerät: {device}") @@ -771,18 +965,21 @@ def synthesize_non_streaming( wavs = [] try: - for i, chunk in enumerate(chunks, start=1): + for i, (chunk, chunk_lang) in enumerate(chunk_pairs, start=1): if stop_event and stop_event.is_set(): if show_progress: print("Abbruch angefordert – Synthese gestoppt.") break + if _wait_while_paused(stop_event): + break if debug_delay > 0: if show_progress: print(f"[{i}/{len(chunks)}] Warte {debug_delay:.0f}s (debug_delay) ...") time.sleep(debug_delay) if show_progress: - print(f"[{i}/{len(chunks)}] Generiere ({len(chunk)} Zeichen) ...") - wav = generate_chunk(model, model_kind, chunk, lang, voice_path) + lang_hint = f" [{chunk_lang}]" if chunk_lang != lang else "" + print(f"[{i}/{len(chunks)}] Generiere ({len(chunk)} Zeichen){lang_hint} ...") + wav = generate_chunk(model, model_kind, chunk, chunk_lang, voice_path) wavs.append(wav) if playback is not None: playback.put(wav) @@ -985,6 +1182,7 @@ def build_argparser() -> argparse.ArgumentParser: p.add_argument("--debug-delay", type=float, default=0.0, help="Sekunden Pause vor jedem Satz (simuliert langsame KI). Nur zum Testen.") p.add_argument("--t3-model", type=str, default="v3", help="Multilingual T3-Modell: 'v3' (default), 'v2' oder Dateiname.") p.add_argument("--no-conversation-mode", action="store_true", help="Ersten Chunk nicht künstlich kleiner machen (nur ohne --no-sentence-mode).") + p.add_argument("--no-strip-markdown", action="store_true", help="Markdown-Formatierung (**, *, #, etc.) nicht aus dem Text entfernen.") p.add_argument("--stop", action="store_true", help="Globales Stop-Signal setzen (für Tests und Service-Integration).") return p @@ -998,8 +1196,17 @@ def main() -> int: print("Stop-Signal gesetzt.") return 0 + if args.speed <= 0: + parser.error(f"--speed muss positiv sein, erhalten: {args.speed}") + if args.first_chunk_len > args.max_len: + parser.error( + f"--first-chunk-len ({args.first_chunk_len}) darf nicht größer sein als --len ({args.max_len})" + ) + try: text = read_input_text(args.text, args.input) + if not args.no_strip_markdown: + text = strip_markdown(text) device = get_device(args.device) output_path = Path(args.output) if args.output else default_output_path(args.input, args.lang) @@ -1015,7 +1222,10 @@ def main() -> int: pron_path = Path(args.pronunciation_dict) if not pron_path.exists(): raise FileNotFoundError(f"Aussprache-Dict nicht gefunden: {pron_path}") - pronunciation_dict = json.loads(pron_path.read_text(encoding="utf-8")) + try: + pronunciation_dict = json.loads(pron_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + raise ValueError(f"Ungültiges JSON in {pron_path}: {e}") from e clear_stop() diff --git a/mcp_adapter.py b/mcp_adapter.py index 804d104..04e168b 100644 --- a/mcp_adapter.py +++ b/mcp_adapter.py @@ -43,6 +43,16 @@ mcp = FastMCP( # Tools # --------------------------------------------------------------------------- +def _raise_for_status(r: httpx.Response) -> None: + """Wirft einen klaren Fehler bei HTTP-4xx/5xx statt rohem httpx-Fehler.""" + try: + r.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"TTS-Service antwortet mit HTTP {exc.response.status_code}: {exc.response.text[:200]}" + ) from exc + + @mcp.tool() async def speak( text: str, @@ -50,6 +60,7 @@ async def speak( voice: str | None = None, interrupt: bool = False, speed: float = 1.0, + session_id: str | None = None, ) -> dict: """Text als Sprache ausgeben. @@ -57,32 +68,52 @@ async def speak( satzweise und beginnt sofort mit der Wiedergabe. Args: - text: Auszugebender Text (max. 4000 Zeichen). - lang: Sprachcode, z. B. 'de', 'en', 'fr'. Standard: 'de'. - voice: Optionaler Pfad zu einer WAV-Referenzdatei (10–30s) für - Voice Cloning. - interrupt: True = laufende Ausgabe sofort unterbrechen und diesen - Text vorgezogen abspielen. - speed: Wiedergabegeschwindigkeit (0.5–2.0). Pitch bleibt gleich. + text: Auszugebender Text (max. 4000 Zeichen). + lang: Sprachcode, z. B. 'de', 'en', 'fr'. Standard: 'de'. + voice: Optionaler Pfad zu einer WAV-Referenzdatei (10–30s) für + Voice Cloning. + interrupt: True = laufende Ausgabe sofort unterbrechen und diesen + Text vorgezogen abspielen. + speed: Wiedergabegeschwindigkeit (0.5–2.0). Pitch bleibt gleich. + session_id: Optionale Session-ID für Job-Tracking im TTS-Service. """ - async with httpx.AsyncClient(timeout=15) as client: + async with httpx.AsyncClient(timeout=30) as client: r = await client.post(f"{TTS_URL}/speak", json={ "text": text, "lang": lang, "voice": voice, "interrupt": interrupt, "speed": speed, + "session_id": session_id, }) - r.raise_for_status() + _raise_for_status(r) return r.json() @mcp.tool() async def stop() -> dict: """Laufende Sprachausgabe sofort stoppen und Warteschlange leeren.""" - async with httpx.AsyncClient(timeout=5) as client: + async with httpx.AsyncClient(timeout=10) as client: r = await client.post(f"{TTS_URL}/stop") - r.raise_for_status() + _raise_for_status(r) + return r.json() + + +@mcp.tool() +async def pause() -> dict: + """Laufende Sprachausgabe pausieren (ohne Datenverlust). Mit resume() fortsetzen.""" + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post(f"{TTS_URL}/pause") + _raise_for_status(r) + return r.json() + + +@mcp.tool() +async def resume() -> dict: + """Pausierte Sprachausgabe fortsetzen.""" + async with httpx.AsyncClient(timeout=10) as client: + r = await client.post(f"{TTS_URL}/resume") + _raise_for_status(r) return r.json() @@ -93,18 +124,18 @@ async def get_status() -> dict: Gibt zurück: laufender Job (mit Chunk-Fortschritt), Queue-Länge und die letzten abgeschlossenen Jobs. """ - async with httpx.AsyncClient(timeout=5) as client: + async with httpx.AsyncClient(timeout=10) as client: r = await client.get(f"{TTS_URL}/status") - r.raise_for_status() + _raise_for_status(r) return r.json() @mcp.tool() async def list_voices() -> dict: """Unterstützte Sprachen und Hinweise zu Voice Cloning abfragen.""" - async with httpx.AsyncClient(timeout=5) as client: + async with httpx.AsyncClient(timeout=10) as client: r = await client.get(f"{TTS_URL}/voices") - r.raise_for_status() + _raise_for_status(r) return r.json() diff --git a/requirements.txt b/requirements.txt index 02c156b..2772b80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,11 +15,15 @@ sounddevice>=0.4.0 pyrubberband>=0.4.0 # --- HTTP-Service (tts_service.py) --- -fastapi>=0.115.0 -uvicorn[standard]>=0.32.0 +fastapi>=0.115.0,<2.0 +uvicorn[standard]>=0.32.0,<1.0 # --- HTTP-Client (mcp_adapter.py → tts_service.py) --- httpx>=0.28.0 # --- MCP-Adapter (mcp_adapter.py) --- mcp>=1.0.0 + +# --- TTS Agent (tts_agent.py) --- +# OpenAI-SDK als universeller Client für Ollama, LM Studio, OpenAI etc. +openai>=1.0.0 diff --git a/tts_agent.py b/tts_agent.py new file mode 100644 index 0000000..66493cf --- /dev/null +++ b/tts_agent.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +TTS Agent — conversational agent with speak/stop/status tools. + +Works with any OpenAI-compatible LLM backend: + - Ollama: python tts_agent.py --model qwen2.5 + - LM Studio: python tts_agent.py --base-url http://localhost:1234/v1 --model local-model + - OpenAI: OPENAI_API_KEY=sk-... python tts_agent.py --model gpt-4o + +The agent automatically calls speak() when it should read text aloud. +Set TTS_URL to override the default TTS service address. + +Pi (Inflection AI) is not supported — it has no public API or function calling. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys + +import httpx + +TTS_URL = os.environ.get("TTS_URL", "http://127.0.0.1:9999").rstrip("/") + +SYSTEM_PROMPT = """Du bist ein hilfreicher Sprachassistent. +Du hast Zugriff auf einen Text-to-Speech-Service mit folgenden Tools: +- speak(text, lang, speed, interrupt): Text vorlesen lassen +- stop(): laufende Ausgabe stoppen +- get_status(): Ausgabe-Status abfragen + +Wenn der Nutzer darum bittet, etwas vorzulesen, oder wenn du eine längere Antwort +gibst, die zum Vorlesen geeignet ist, ruf speak() mit dem entsprechenden Text auf. +Antworte auf Deutsch, sofern nicht anders gewünscht.""" + +TOOLS = [ + { + "type": "function", + "function": { + "name": "speak", + "description": ( + "Text als Sprache ausgeben. Reiht den Text in die Warteschlange ein " + "und gibt sofort zurück. Ideal für längere Texte oder wenn der Nutzer " + "etwas vorgelesen haben möchte." + ), + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Auszugebender Text (max. 4000 Zeichen).", + }, + "lang": { + "type": "string", + "default": "de", + "description": "Sprachcode: 'de', 'en', 'fr', 'es', … Standard: 'de'.", + }, + "speed": { + "type": "number", + "default": 1.0, + "description": "Geschwindigkeit 0.5–2.0. Standard: 1.0.", + }, + "interrupt": { + "type": "boolean", + "default": False, + "description": "True = laufende Ausgabe sofort unterbrechen.", + }, + }, + "required": ["text"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "stop", + "description": "Laufende Sprachausgabe sofort stoppen und Warteschlange leeren.", + "parameters": {"type": "object", "properties": {}}, + }, + }, + { + "type": "function", + "function": { + "name": "get_status", + "description": "Aktuellen Ausgabe-Status abfragen (laufender Job, Queue-Länge).", + "parameters": {"type": "object", "properties": {}}, + }, + }, +] + + +def _call_tts(name: str, args: dict) -> str: + try: + with httpx.Client(timeout=15) as client: + if name == "speak": + r = client.post(f"{TTS_URL}/speak", json=args) + elif name == "stop": + r = client.post(f"{TTS_URL}/stop") + elif name == "get_status": + r = client.get(f"{TTS_URL}/status") + else: + return json.dumps({"error": f"Unbekanntes Tool: {name}"}) + r.raise_for_status() + return r.text + except httpx.ConnectError: + return json.dumps({"error": f"TTS-Service nicht erreichbar: {TTS_URL}"}) + except Exception as exc: # noqa: BLE001 + return json.dumps({"error": str(exc)}) + + +def run_agent(model: str, base_url: str, system_prompt: str, voice: str | None) -> None: + try: + from openai import OpenAI + except ImportError: + print("Fehler: 'openai' nicht installiert. → pip install openai", file=sys.stderr) + sys.exit(1) + + client = OpenAI( + base_url=base_url, + api_key=os.environ.get("OPENAI_API_KEY", "ollama"), + ) + + if voice: + system_prompt += f"\n\nWenn du speak() aufrufst, übergib immer voice='{voice}'." + + messages: list[dict] = [{"role": "system", "content": system_prompt}] + + # IMPROVE-7: TTS-Service-Erreichbarkeit früh prüfen + try: + with httpx.Client(timeout=5) as _hc: + _hc.get(f"{TTS_URL}/health") + except Exception as _e: + print(f"Fehler: TTS-Service nicht erreichbar ({TTS_URL}): {_e}", file=sys.stderr) + sys.exit(1) + + print(f"Agent gestartet | Modell: {model} | TTS: {TTS_URL}") + print("Beenden mit 'exit' oder Ctrl+C.\n") + + while True: + try: + user_input = input("Du: ").strip() + except (KeyboardInterrupt, EOFError): + print("\nTschüss!") + break + + if user_input.lower() in ("exit", "quit", "bye", "tschüss"): + print("Tschüss!") + break + if not user_input: + continue + + messages.append({"role": "user", "content": user_input}) + + # BUG-3: Maximale Iterationen verhindern Endlosschleife bei LLMs, + # die wiederholt Tool-Calls ohne abschließende Antwort produzieren. + max_iterations = 10 + for _iteration in range(max_iterations): + try: + resp = client.chat.completions.create( + model=model, + messages=messages, + tools=TOOLS, + tool_choice="auto", + ) + except Exception as exc: # noqa: BLE001 + print(f"[LLM-Fehler] {exc}", file=sys.stderr) + break + + msg = resp.choices[0].message + # BUG-6: explizites Dict statt model_dump(exclude_unset=True), + # damit 'role' auch bei strict-kompatiblen Backends immer vorhanden ist. + msg_dict: dict = {"role": msg.role, "content": msg.content} + if msg.tool_calls: + msg_dict["tool_calls"] = [tc.model_dump() for tc in msg.tool_calls] + messages.append(msg_dict) + + if not msg.tool_calls: + if msg.content: + print(f"\nAssistent: {msg.content}\n") + break + + for tc in msg.tool_calls: + args = json.loads(tc.function.arguments) + arg_str = ", ".join(f"{k}={v!r}" for k, v in args.items()) + print(f" [{tc.function.name}({arg_str})]") + result = _call_tts(tc.function.name, args) + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": result, + }) + else: + print(f"[Agent] Maximale Tool-Call-Iterationen ({max_iterations}) erreicht.", file=sys.stderr) + + +def main() -> None: + p = argparse.ArgumentParser( + description="TTS Agent — LLM mit speak/stop/status Tools", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + p.add_argument( + "--model", default="qwen2.5", + help="Modellname (Standard: qwen2.5). Für OpenAI z. B. 'gpt-4o'.", + ) + p.add_argument( + "--base-url", default="http://localhost:11434/v1", + help="OpenAI-kompatibler API-Endpunkt (Standard: Ollama auf Port 11434).", + ) + p.add_argument( + "--system-prompt", default=SYSTEM_PROMPT, + help="System-Prompt überschreiben.", + ) + p.add_argument( + "--voice", default=None, + help="Pfad zu einer WAV-Referenzdatei für Voice Cloning (wird an speak() weitergegeben).", + ) + p.add_argument( + "--lang", default=None, + help="Sprache für speak() überschreiben (z. B. 'en'). Standard: Modell entscheidet.", + ) + args = p.parse_args() + + system = args.system_prompt + if args.lang: + system += f"\n\nVerwende immer lang='{args.lang}' beim Aufruf von speak()." + + run_agent( + model=args.model, + base_url=args.base_url, + system_prompt=system, + voice=args.voice, + ) + + +if __name__ == "__main__": + main() diff --git a/tts_service.py b/tts_service.py index 00ecbbd..0161fda 100644 --- a/tts_service.py +++ b/tts_service.py @@ -52,6 +52,18 @@ def _get_or_load_model(lang: str, t3_model: str) -> tuple: return _model_cache[key] +# Optionaler Warmup: TTS_PRELOAD_LANG=de lädt das Modell beim Service-Start, +# damit der erste Request keine Modell-Ladezeit hat. +_PRELOAD_LANG = __import__("os").environ.get("TTS_PRELOAD_LANG") +if _PRELOAD_LANG: + _preload_t3 = __import__("os").environ.get("TTS_PRELOAD_T3", "v3") + try: + _get_or_load_model(_PRELOAD_LANG, _preload_t3) + print(f"[chatterbox-tts] Modell vorgeladen: lang={_PRELOAD_LANG}, t3={_preload_t3}") + except Exception as _e: + print(f"[chatterbox-tts] Warnung: Warmup fehlgeschlagen: {_e}") + + # --------------------------------------------------------------------------- # Job-Datenmodell # --------------------------------------------------------------------------- @@ -123,7 +135,7 @@ def _worker() -> None: playback = tts.PlaybackWorker( sample_rate=sr, - device=job.audio_device, + device=job.audio_device or "pulse", speed=job.speed, stop_event=tts.STOP_REQUESTED, ) @@ -269,6 +281,18 @@ def stop(): return {"stopped": True} +@app.post("/pause") +def pause(): + tts.request_pause() + return {"paused": True} + + +@app.post("/resume") +def resume(): + tts.request_resume() + return {"resumed": True} + + @app.get("/status") def status(): with _state_lock: