Bugfixes, Verbesserungen und Mixed-Language-Support

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 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-03 11:36:54 +02:00
commit 34a34907a8
8 changed files with 778 additions and 114 deletions

View file

@ -152,9 +152,9 @@ Du kannst das mit einer einfachen Textdatei im JSON-Format korrigieren.
```json ```json
{ {
"Xi Jinping": "Schi Dschinping",
"Seoul": "Söul", "Seoul": "Söul",
"Macron": "Makron" "Macron": "Makron",
"Kubernetes": "Kubernetis"
} }
``` ```
@ -166,6 +166,9 @@ python chatterbox_cli_v4.py --lang de \
--input nachricht.txt --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 ## Den Service aus dem Netzwerk nutzen
@ -188,6 +191,12 @@ curl -X POST http://COMPUTER-IP:9999/speak \
hostname -I 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:** **Ausgabe stoppen:**
```bash ```bash
curl -X POST http://COMPUTER-IP:9999/stop 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 (Claude Code / Claude Desktop)
Claude ist bereits mit dem TTS-Service verbunden. Du kannst Claude einfach bitten, 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: ..." > „Lies mir bitte diesen Text vor: ..."
> „Pause bitte."
> „Weiter."
### Home Assistant ### Home Assistant
@ -219,6 +230,14 @@ rest_command:
method: POST method: POST
content_type: "application/json" content_type: "application/json"
payload: '{"text": "{{ text }}", "lang": "de"}' 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: Danach in einer Automation verwendbar:
@ -249,11 +268,15 @@ Service verbunden werden — Details in der `README.md`.
## Was das Programm automatisch macht ## Was das Programm automatisch macht
- Abkürzungen buchstabieren: ARD wird zu „Ah Er De", YMCA zu „Ypsilon Em Tse Ah" - **Markdown-Formatierung bereinigen**: `**fett**`, `# Überschrift`, `- Listen` und Links werden vor der Sprachausgabe entfernt
- Zusammengesetzte Wörter mit Abkürzung: „US-Präsident" wird zu „U Es Präsident" - **Emojis entfernen**: Smileys und Symbole (😊, 🎉) werden still übergangen
- Uhrzeiten vorlesen: „14:58" wird zu „vierzehn Uhr achtundfünfzig" - **Abkürzungen buchstabieren**: ARD wird zu „Ah Er De", YMCA zu „Ypsilon Em Tse Ah"
- Jahreszahlen aussprechen: „2026" wird zu „zweitausendsechsundzwanzig" - **Tech-Abkürzungen richtig sprechen**: CPU, GPU, USB, API, JSON u. a. werden lateinisch buchstabiert (nicht auf Deutsch)
- Trennzeilen wie „--- Ende ---" werden stillschweigend übersprungen - **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 3060 Sekunden brauchen. Mit GPU (CUDA) dauert es ca. 510 Sekunden.
- **Betonung einzelner Wörter** lässt sich nicht direkt steuern. - **Betonung einzelner Wörter** lässt sich nicht direkt steuern.
Eine Aufnahme der eigenen Stimme mit natürlicher Betonung kann helfen. Eine Aufnahme der eigenen Stimme mit natürlicher Betonung kann helfen.
- **Manche Fremdwörter** (z. B. chinesische oder arabische Namen) klingen - **Manche Fremdwörter** klingen nicht immer perfekt — mit der Aussprache-Datei lässt sich das korrigieren.
nicht immer perfekt — mit der Aussprache-Datei lässt sich das korrigieren.
- Das Programm liest alles vor, was in der Datei steht — also auch - Das Programm liest alles vor, was in der Datei steht — also auch
Überschriften und Metadaten wie „Schlagzeile:" oder „Stand:". Überschriften und Metadaten wie „Schlagzeile:" oder „Stand:".
- Eine laufende Ausgabe kann erst am Ende des aktuellen Satzes unterbrochen - Eine laufende Ausgabe kann erst am Ende des aktuellen Satzes unterbrochen

View file

@ -27,6 +27,12 @@ python chatterbox_cli_v4.py --lang de --stream --input text.txt
# Aussprache-Wörterbuch (JSON: {"Eigenname": "Lautschrift"}) # Aussprache-Wörterbuch (JSON: {"Eigenname": "Lautschrift"})
python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --input text.txt 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. 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): # Manuell starten (Port 9999, LAN-weit erreichbar):
uvicorn tts_service:app --host 0.0.0.0 --port 9999 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: # Health-Check:
curl http://127.0.0.1:9999/health 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 ## 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 TTS_URL=http://192.168.1.10:9999 python mcp_adapter.py --stdio
``` ```
MCP-Tools: `speak`, `stop`, `pause`, `resume`, `get_status`, `list_voices`
## Architecture ## Architecture
### Files ### 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 | | `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 | | `tts_service.py` | FastAPI-Service mit Job-Queue und Worker-Thread |
| `mcp_adapter.py` | MCP-Wrapper über die REST-API | | `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`) ### 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). 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 ```python
STOP_REQUESTED = threading.Event() STOP_REQUESTED = threading.Event()
request_stop() # setzt das Event PAUSE_REQUESTED = threading.Event()
clear_stop() # löscht es vor jedem neuen Job
stop_requested() # abfragen 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`) ### Text normalization (`preprocess_tts_text`)
1. Pronunciation dict (vor Akronym-Expansion, damit Eigennamen zuerst greifen) 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") 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) 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 ### 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) - **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` - **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`) ### Model loading (`load_model`)
- `--lang en``ChatterboxTTS` (mono, immer verfügbar) - `--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 - `--t3-model v3` (default) oder `v2` wählt den multilingualen T3-Checkpoint
- Modelle werden in `~/.cache/huggingface/` gecacht (~23 GB) - Modelle werden in `~/.cache/huggingface/` gecacht (~23 GB)
- **Kritisch**: `attn_implementation = "eager"` wird beim Import erzwungen — SDPA gibt `None`-Attention-Weights zurück und bricht den `AlignmentStreamAnalyzer`-Hook - **Kritisch**: `attn_implementation = "eager"` wird beim Import erzwungen — SDPA gibt `None`-Attention-Weights zurück und bricht den `AlignmentStreamAnalyzer`-Hook
### Audio output (`PlaybackWorker`) ### 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) - `sounddevice.OutputStream` mit Callback bei 48 kHz (PipeWire/PulseAudio-Standard)
- Interner Producer-Thread: Torch-Tensoren → `CALLBACK_BLOCK`-große (2048 Samples) numpy-Arrays - 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)` - `--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 - `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 ### 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 - **`synthesize_streaming`**: ruft `model.generate_stream()` mit `chunk_size` auf; jeder Audio-Sub-Chunk geht direkt in `PlaybackWorker`; experimentell
### HTTP Service (`tts_service.py`) ### 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-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 - **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 - **`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 - **Status**: `_current_job`, `_recent_jobs` (max. 20) via `_state_lock` thread-safe lesbar

124
README.md
View file

@ -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 - **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 - **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` - **Geschwindigkeitsanpassung** — pitch-erhaltende Zeitstreckung via pyrubberband (R3-Engine); `--speed 0.5``2.0`
- **Voice Cloning** — optionale WAV-Referenz für Akzent und Klang - **Voice Cloning** — optionale WAV-Referenz für Akzent und Klang
- **Mehrsprachig** — Deutsch, Englisch und 20+ weitere Sprachen via `ChatterboxMultilingualTTS` - **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 - **Gemischtsprachige Texte**`[en]...[/en]`-Markierungen für englische Passagen in deutschen Texten
- **HTTP-Service** — FastAPI-Service mit Job-Queue, Stop/Interrupt, Status-Endpunkt - **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 - **MCP-Adapter** — direkte Integration in Claude Code, Claude Desktop und andere MCP-Hosts
- **Systemd-Autostart** — Service startet automatisch beim Login - **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 # Aussprache-Wörterbuch
python chatterbox_cli_v4.py --lang de --pronunciation-dict aussprache.json --input text.txt 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 ### 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` | | `--t3-model` | `v3` | Multilingual-Modell: `v3` oder `v2` |
| `--acronym-mode` | `german` | Akronym-Modus: `german`, `space`, `period_space` | | `--acronym-mode` | `german` | Akronym-Modus: `german`, `space`, `period_space` |
| `--pronunciation-dict` | — | JSON-Datei mit Aussprache-Substitutionen | | `--pronunciation-dict` | — | JSON-Datei mit Aussprache-Substitutionen |
| `--no-strip-markdown` | — | Markdown-Formatierung nicht entfernen |
| `--save` | nein | WAV-Datei speichern | | `--save` | nein | WAV-Datei speichern |
| `--output DATEI.wav` | — | Ausgabepfad (impliziert `--save`) | | `--output DATEI.wav` | — | Ausgabepfad (impliziert `--save`) |
| `--no-play` | — | Nicht live abspielen | | `--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`) ## HTTP-Service (`tts_service.py`)
FastAPI-Service mit Job-Queue und Worker-Thread. Startet automatisch via systemd. 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 # Manueller Start
uvicorn tts_service:app --host 0.0.0.0 --port 9999 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) # Systemd (Autostart, läuft bereits)
systemctl --user status chatterbox-tts systemctl --user status chatterbox-tts
systemctl --user restart chatterbox-tts systemctl --user restart chatterbox-tts
@ -117,6 +158,8 @@ journalctl --user -u chatterbox-tts -f
|---------|------|----------| |---------|------|----------|
| `POST` | `/speak` | Text in Queue einreihen | | `POST` | `/speak` | Text in Queue einreihen |
| `POST` | `/stop` | Ausgabe abbrechen, Queue leeren | | `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` | `/health` | Service-Status und Gerät |
| `GET` | `/status` | Aktueller Job, Queue-Länge, letzte Jobs | | `GET` | `/status` | Aktueller Job, Queue-Länge, letzte Jobs |
| `GET` | `/voices` | Unterstützte Sprachen | | `GET` | `/voices` | Unterstützte Sprachen |
@ -135,7 +178,8 @@ journalctl --user -u chatterbox-tts -f
"max_len": 400, "max_len": 400,
"save_wav": false, "save_wav": false,
"output_path": null, "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 # Laufende Ausgabe unterbrechen
curl -X POST http://localhost:9999/speak \ 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 # Stoppen
curl -X POST http://localhost:9999/stop 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 | | Tool | Parameter | Funktion |
|------|-----------|----------| |------|-----------|----------|
| `speak` | text, lang, voice, interrupt, speed | Text ausgeben | | `speak` | text, lang, voice, interrupt, speed, session_id | Text ausgeben |
| `stop` | — | Ausgabe stoppen | | `stop` | — | Ausgabe stoppen und Queue leeren |
| `get_status` | — | Aktuellen Job abfragen | | `pause` | — | Ausgabe pausieren (ohne Datenverlust) |
| `list_voices` | — | Sprachen auflisten | | `resume` | — | Pausierte Ausgabe fortsetzen |
| `get_status` | — | Aktuellen Job und Queue abfragen |
| `list_voices` | — | Unterstützte Sprachen auflisten |
### Claude Code Konfiguration ### Claude Code Konfiguration
@ -214,7 +264,7 @@ claude mcp add --scope user chatterbox-tts \
### Claude Code / Claude Desktop — MCP (fertig eingerichtet) ### 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. Kein weiterer Setup nötig.
### Ollama (llama3.2, qwen2.5, mistral-nemo u. a.) ### 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) 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 ### Open WebUI
Im Open-WebUI-Menü unter *Tools* eine neue Python-Klasse anlegen: Im Open-WebUI-Menü unter *Tools* eine neue Python-Klasse anlegen:
@ -268,17 +336,6 @@ class Tools:
return "stopped" 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 ### Home Assistant
```yaml ```yaml
@ -293,6 +350,14 @@ rest_command:
tts_stop: tts_stop:
url: "http://192.168.x.x:9999/stop" url: "http://192.168.x.x:9999/stop"
method: POST 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: Aufruf in einer Automation:
@ -307,19 +372,18 @@ data:
HTTP-Request-Node direkt auf `POST http://<host>:9999/speak` mit JSON-Body. HTTP-Request-Node direkt auf `POST http://<host>:9999/speak` mit JSON-Body.
Kein weiterer Setup nötig. Kein weiterer Setup nötig.
### Pi (Inflection AI)
Keine Tool-API verfügbar — direkte Integration nicht möglich.
--- ---
## Aussprache-Wörterbuch ## Aussprache-Wörterbuch
Für Namen oder Begriffe, die das Modell falsch ausspricht:
```json ```json
{ {
"Xi Jinping": "Schi Dschinping", "Xi Jinping": "Schi Dschinping",
"Putin": "Pjutin", "Putin": "Pjutin",
"Seoul": "Söul" "Seoul": "Söul",
"Kubernetes": "Kubernetis"
} }
``` ```
@ -329,13 +393,17 @@ python chatterbox_cli_v4.py --lang de \
--input nachricht.txt --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 ## Bekannte Einschränkungen
- **Wortbetonung** lässt sich nicht steuern — kein SSML. Abhilfe: Voice-Referenz mit gewünschter Betonung. - **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. - **Laufendes `model.generate()`** kann nicht mid-call abgebrochen werden (Python-Thread-Grenzen); Stop/Pause greift am nächsten Chunk-Beginn.
- **Chinesische/japanische Namen** werden phonetisch angenähert. - **Sprachmarkierungen `[en]...[/en]`** funktionieren nur mit `ChatterboxMultilingualTTS`; bei `--lang en` (mono) werden sie ignoriert.
- **Streaming-Modus** unterstützt keine Sprachmarkierungen.
--- ---

View file

@ -11,19 +11,45 @@ from pathlib import Path
from typing import List, Optional, Tuple 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: def request_stop() -> None:
STOP_REQUESTED.set() STOP_REQUESTED.set()
PAUSE_REQUESTED.clear() # eine laufende Pause beim Stop aufheben
def clear_stop() -> None: def clear_stop() -> None:
STOP_REQUESTED.clear() STOP_REQUESTED.clear()
def stop_requested() -> bool: def stop_requested() -> bool:
return STOP_REQUESTED.is_set() 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 torch
import torchaudio as ta import torchaudio as ta
@ -37,7 +63,7 @@ from chatterbox.tts import ChatterboxTTS
try: try:
from chatterbox.mtl_tts import ChatterboxMultilingualTTS from chatterbox.mtl_tts import ChatterboxMultilingualTTS
HAS_MULTILINGUAL = True HAS_MULTILINGUAL = True
except Exception: except (ImportError, ModuleNotFoundError):
ChatterboxMultilingualTTS = None ChatterboxMultilingualTTS = None
HAS_MULTILINGUAL = False HAS_MULTILINGUAL = False
@ -58,11 +84,26 @@ SENTENCE_END_RE = re.compile(
re.DOTALL 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 = { NON_SPELLED_ACRONYMS = {
"NATO", # Internationale Organisationen / Eigennamen (werden als Wort gesprochen)
"NASA", "NATO", "NASA", "UNESCO", "OPEC", "IAEA", "UNICEF",
"UNESCO", # Tech-Akronyme, die buchstabenweise ausgesprochen werden sollen,
"OPEC", # 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 = { 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 # Vierstellige Jahreszahlen
YEAR_RE = re.compile(r'\b(19\d{2}|20\d{2}|21\d{2})\b') 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 = { UNIT_REPLACEMENTS = {
# Geschwindigkeit
"km/h": "Kilometer pro Stunde", "km/h": "Kilometer pro Stunde",
"m/s": "Meter pro Sekunde",
"rpm": "Umdrehungen pro Minute",
# Länge
"km": "Kilometer", "km": "Kilometer",
"m": "Meter",
"cm": "Zentimeter", "cm": "Zentimeter",
"mm": "Millimeter", "mm": "Millimeter",
"m": "Meter",
# Fläche / Volumen
"cm²": "Quadratzentimeter",
"": "Quadratmeter",
"": "Kubikmeter",
# Masse
"kg": "Kilogramm", "kg": "Kilogramm",
"g": "Gramm",
"mg": "Milligramm", "mg": "Milligramm",
"Hz": "Hertz", "g": "Gramm",
"kHz": "Kilohertz", # Temperatur
"MHz": "Megahertz", "°C": "Grad Celsius",
"°F": "Grad Fahrenheit",
# Elektrik / Energie
"kWh": "Kilowattstunde",
"kW": "Kilowatt",
"W": "Watt",
"V": "Volt",
"A": "Ampere",
"J": "Joule",
# Frequenz
"GHz": "Gigahertz", "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", "": "Euro",
"$": "Dollar", "$": "Dollar",
"%": "Prozent", "%": "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] = { DEFAULT_PRONUNCIATION_DE: dict[str, str] = {
"Xi Jinping": "Schi Jinping", # Chinesische Eigennamen
"Xi": "Schi", "Xi Jinping": "Schi Jinping",
"Jinping": "Jinping", "Xi": "Schi",
"Peking": "Peking", # bleibt — deutsches TTS kennt es "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 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: 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 ('', '', '', ''): for ch in ('', '', '', ''):
text = text.replace(ch, '') 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 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: def has_module(name: str) -> bool:
return importlib.util.find_spec(name) is not None 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" 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 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) return str(n)
@ -233,6 +392,18 @@ def number_to_words_en(n: int) -> str:
prefix = f"{number_to_words_en(th)} thousand" prefix = f"{number_to_words_en(th)} thousand"
return prefix if r == 0 else f"{prefix} {number_to_words_en(r)}" 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) 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]: 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 = [] result = []
for part in text.split("\n\n"): for part in text.split("\n\n"):
part = part.strip() part = part.strip()
@ -483,13 +658,15 @@ def split_into_sentences(text: str, max_len: int = 200) -> List[str]:
continue continue
if SEPARATOR_LINE_RE.match(part): if SEPARATOR_LINE_RE.match(part):
continue 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() consumed = "".join(sentences).strip()
rest = part[len(consumed):].strip() rest = masked[len(consumed):].strip()
if rest: if rest:
sentences.append(rest) sentences.append(rest)
for sentence in sentences: for sentence in sentences:
sentence = sentence.strip() sentence = sentence.replace("\x00", ".").strip()
if not sentence: if not sentence:
continue continue
if len(sentence) > max_len: if len(sentence) > max_len:
@ -592,12 +769,21 @@ class PlaybackWorker:
raise RuntimeError( raise RuntimeError(
"Für Live-Wiedergabe ist das Modul 'sounddevice' nötig. Installiere z. B. 'pip install sounddevice'." "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 = threading.Thread(target=self._run, daemon=True)
self.thread.start() self.thread.start()
def _callback(self, outdata, frames, time_info, status): def _callback(self, outdata, frames, time_info, status):
# Läuft im Audio-Thread: so schnell wie möglich, kein Lock nötig. # 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 outdata[:] = 0.0
return return
try: try:
@ -729,15 +915,10 @@ def synthesize_non_streaming(
model, model_kind, sr = load_model(lang, device, t3_model=t3_model) model, model_kind, sr = load_model(lang, device, t3_model=t3_model)
if sentence_mode: # [xx]...[/xx]-Sprachmarkierungen extrahieren; ohne Markierungen → ein Span in default lang.
raw_chunks = split_into_sentences(text, max_len=max_len) lang_spans = extract_language_spans(text, lang)
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( preprocess_kw = dict(
lang=lang,
spell_uppercase_acronyms=spell_uppercase_acronyms, spell_uppercase_acronyms=spell_uppercase_acronyms,
acronym_mode=acronym_mode, acronym_mode=acronym_mode,
normalize_time_values=normalize_time_values, normalize_time_values=normalize_time_values,
@ -745,12 +926,25 @@ def synthesize_non_streaming(
normalize_units_values=normalize_units_values, normalize_units_values=normalize_units_values,
pronunciation_dict=pronunciation_dict, pronunciation_dict=pronunciation_dict,
) )
chunks = [preprocess_tts_text(c, **preprocess_kw) for c in raw_chunks] # chunk_pairs: Liste von (verarbeiteter_Text, chunk_lang)
chunks = [c for c in chunks if c.strip()] 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.") raise ValueError("Kein verwertbarer Text nach dem Einlesen gefunden.")
chunks = [t for t, _ in chunk_pairs] # für Progress-Anzeige
if show_progress: if show_progress:
print(f"Sprache: {lang}") print(f"Sprache: {lang}")
print(f"Gerät: {device}") print(f"Gerät: {device}")
@ -771,18 +965,21 @@ def synthesize_non_streaming(
wavs = [] wavs = []
try: 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 stop_event and stop_event.is_set():
if show_progress: if show_progress:
print("Abbruch angefordert Synthese gestoppt.") print("Abbruch angefordert Synthese gestoppt.")
break break
if _wait_while_paused(stop_event):
break
if debug_delay > 0: if debug_delay > 0:
if show_progress: if show_progress:
print(f"[{i}/{len(chunks)}] Warte {debug_delay:.0f}s (debug_delay) ...") print(f"[{i}/{len(chunks)}] Warte {debug_delay:.0f}s (debug_delay) ...")
time.sleep(debug_delay) time.sleep(debug_delay)
if show_progress: if show_progress:
print(f"[{i}/{len(chunks)}] Generiere ({len(chunk)} Zeichen) ...") lang_hint = f" [{chunk_lang}]" if chunk_lang != lang else ""
wav = generate_chunk(model, model_kind, chunk, lang, voice_path) 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) wavs.append(wav)
if playback is not None: if playback is not None:
playback.put(wav) 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("--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("--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-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).") p.add_argument("--stop", action="store_true", help="Globales Stop-Signal setzen (für Tests und Service-Integration).")
return p return p
@ -998,8 +1196,17 @@ def main() -> int:
print("Stop-Signal gesetzt.") print("Stop-Signal gesetzt.")
return 0 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: try:
text = read_input_text(args.text, args.input) text = read_input_text(args.text, args.input)
if not args.no_strip_markdown:
text = strip_markdown(text)
device = get_device(args.device) device = get_device(args.device)
output_path = Path(args.output) if args.output else default_output_path(args.input, args.lang) 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) pron_path = Path(args.pronunciation_dict)
if not pron_path.exists(): if not pron_path.exists():
raise FileNotFoundError(f"Aussprache-Dict nicht gefunden: {pron_path}") 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() clear_stop()

View file

@ -43,6 +43,16 @@ mcp = FastMCP(
# Tools # 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() @mcp.tool()
async def speak( async def speak(
text: str, text: str,
@ -50,6 +60,7 @@ async def speak(
voice: str | None = None, voice: str | None = None,
interrupt: bool = False, interrupt: bool = False,
speed: float = 1.0, speed: float = 1.0,
session_id: str | None = None,
) -> dict: ) -> dict:
"""Text als Sprache ausgeben. """Text als Sprache ausgeben.
@ -57,32 +68,52 @@ async def speak(
satzweise und beginnt sofort mit der Wiedergabe. satzweise und beginnt sofort mit der Wiedergabe.
Args: Args:
text: Auszugebender Text (max. 4000 Zeichen). text: Auszugebender Text (max. 4000 Zeichen).
lang: Sprachcode, z. B. 'de', 'en', 'fr'. Standard: 'de'. lang: Sprachcode, z. B. 'de', 'en', 'fr'. Standard: 'de'.
voice: Optionaler Pfad zu einer WAV-Referenzdatei (1030s) für voice: Optionaler Pfad zu einer WAV-Referenzdatei (1030s) für
Voice Cloning. Voice Cloning.
interrupt: True = laufende Ausgabe sofort unterbrechen und diesen interrupt: True = laufende Ausgabe sofort unterbrechen und diesen
Text vorgezogen abspielen. Text vorgezogen abspielen.
speed: Wiedergabegeschwindigkeit (0.52.0). Pitch bleibt gleich. speed: Wiedergabegeschwindigkeit (0.52.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={ r = await client.post(f"{TTS_URL}/speak", json={
"text": text, "text": text,
"lang": lang, "lang": lang,
"voice": voice, "voice": voice,
"interrupt": interrupt, "interrupt": interrupt,
"speed": speed, "speed": speed,
"session_id": session_id,
}) })
r.raise_for_status() _raise_for_status(r)
return r.json() return r.json()
@mcp.tool() @mcp.tool()
async def stop() -> dict: async def stop() -> dict:
"""Laufende Sprachausgabe sofort stoppen und Warteschlange leeren.""" """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 = 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() return r.json()
@ -93,18 +124,18 @@ async def get_status() -> dict:
Gibt zurück: laufender Job (mit Chunk-Fortschritt), Queue-Länge und Gibt zurück: laufender Job (mit Chunk-Fortschritt), Queue-Länge und
die letzten abgeschlossenen Jobs. 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 = await client.get(f"{TTS_URL}/status")
r.raise_for_status() _raise_for_status(r)
return r.json() return r.json()
@mcp.tool() @mcp.tool()
async def list_voices() -> dict: async def list_voices() -> dict:
"""Unterstützte Sprachen und Hinweise zu Voice Cloning abfragen.""" """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 = await client.get(f"{TTS_URL}/voices")
r.raise_for_status() _raise_for_status(r)
return r.json() return r.json()

View file

@ -15,11 +15,15 @@ sounddevice>=0.4.0
pyrubberband>=0.4.0 pyrubberband>=0.4.0
# --- HTTP-Service (tts_service.py) --- # --- HTTP-Service (tts_service.py) ---
fastapi>=0.115.0 fastapi>=0.115.0,<2.0
uvicorn[standard]>=0.32.0 uvicorn[standard]>=0.32.0,<1.0
# --- HTTP-Client (mcp_adapter.py → tts_service.py) --- # --- HTTP-Client (mcp_adapter.py → tts_service.py) ---
httpx>=0.28.0 httpx>=0.28.0
# --- MCP-Adapter (mcp_adapter.py) --- # --- MCP-Adapter (mcp_adapter.py) ---
mcp>=1.0.0 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

237
tts_agent.py Normal file
View file

@ -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.52.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()

View file

@ -52,6 +52,18 @@ def _get_or_load_model(lang: str, t3_model: str) -> tuple:
return _model_cache[key] 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 # Job-Datenmodell
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -123,7 +135,7 @@ def _worker() -> None:
playback = tts.PlaybackWorker( playback = tts.PlaybackWorker(
sample_rate=sr, sample_rate=sr,
device=job.audio_device, device=job.audio_device or "pulse",
speed=job.speed, speed=job.speed,
stop_event=tts.STOP_REQUESTED, stop_event=tts.STOP_REQUESTED,
) )
@ -269,6 +281,18 @@ def stop():
return {"stopped": True} 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") @app.get("/status")
def status(): def status():
with _state_lock: with _state_lock: