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:
parent
d1971049ce
commit
34a34907a8
8 changed files with 778 additions and 114 deletions
|
|
@ -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 30–60 Sekunden brauchen. Mit GPU (CUDA) dauert es ca. 5–10 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
|
||||||
|
|
|
||||||
94
CLAUDE.md
94
CLAUDE.md
|
|
@ -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
|
|
||||||
|
request_stop() # setzt STOP_REQUESTED, löscht PAUSE_REQUESTED
|
||||||
|
clear_stop() # löscht STOP_REQUESTED (vor neuem Job)
|
||||||
stop_requested() # abfragen
|
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 (~2–3 GB)
|
- 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
|
- **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
124
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
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
"m²": "Quadratmeter",
|
||||||
|
"m³": "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] = {
|
||||||
|
# Chinesische Eigennamen
|
||||||
"Xi Jinping": "Schi Jinping",
|
"Xi Jinping": "Schi Jinping",
|
||||||
"Xi": "Schi",
|
"Xi": "Schi",
|
||||||
"Jinping": "Jinping",
|
"Jinping": "Jinping",
|
||||||
"Peking": "Peking", # bleibt — deutsches TTS kennt es
|
"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
|
||||||
|
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}")
|
||||||
|
try:
|
||||||
pronunciation_dict = json.loads(pron_path.read_text(encoding="utf-8"))
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -64,25 +75,45 @@ async def speak(
|
||||||
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.5–2.0). Pitch bleibt gleich.
|
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={
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
237
tts_agent.py
Normal 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.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()
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue