From 34a34907a8c87d641fa217802168928b464e41b0 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 3 Jun 2026 11:36:54 +0200 Subject: [PATCH] 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: