diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 80f062a..1033f6a 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -201,8 +201,57 @@ python chatterbox_cli_v4.py --lang de \ --input nachricht.txt ``` -Häufige englische Begriffe wie „GitHub" (→ „Git Hab"), „YouTube" (→ „Jutjub") oder -„Wi-Fi" (→ „Wai Fai") werden bereits automatisch korrekt ausgesprochen. +### Das mitgelieferte Wörterbuch + +Rund 200 Begriffe sind **schon eingebaut** und werden automatisch korrigiert — englische +Fachbegriffe („Repository" → „Ripositori", „Deployment" → „Diploiment"), Eigennamen +(„Donald Trump" → „Donald Tramp"), Firmen („Nvidia" → „Enwidia") und Abkürzungen +(„GPU" → „Geh Peh Uh"). Nachsehen, was drinsteht: + +```bash +python pronunciation.py de +``` + +Gepflegt wird die Liste in `pronunciation/de.json`. Eigene Begriffe kannst du auf drei +Wegen ergänzen — jede Stufe überschreibt die vorherige: + +| Wo | Wofür | +|---|---| +| `pronunciation/de.json` | die gemeinsame Liste, gilt für **alle** Programme (CLI, Service, MCP) | +| `~/.config/chatterbox/pronunciation/de.json` | deine persönlichen Ergänzungen | +| `CHATTERBOX_PRONUNCIATION_DICT=/pfad/zu.json` | einmalig, z. B. für ein einzelnes Projekt | +| `--pronunciation-dict datei.json` | nur für diesen einen Aufruf | + +### Nach dem Bearbeiten: Dienste neu starten + +Die Kommandozeile liest das Wörterbuch bei jedem Aufruf neu — dort wirkt eine Änderung sofort. +Die **Dienste** halten es dagegen im Speicher und müssen neu gestartet werden: + +```bash +systemctl --user restart chatterbox-tts # HTTP-Service / MCP (Port 9999) +systemctl --user restart open-notebook-tts # Open Notebook (Port 8901), falls genutzt +``` + +Vergisst du das, sprechen die Dienste weiter die alte Aussprache — ohne Fehlermeldung. + +### Neue Begriffe finden statt raten + +Welche Wörter falsch klingen, muss man nicht erraten. Das Hilfsprogramm sucht in einem Text +alles, was ein deutsches Wörterbuch nicht kennt — das sind fast immer genau die englischen +Fachbegriffe und fremden Namen: + +```bash +python pronunciation_candidates.py mein_text.txt # Liste, häufigste zuerst +python pronunciation_candidates.py mein_text.txt --json # Gerüst zum Ausfüllen +``` + +Die Umschrift schreibst du selbst — sie soll so aussehen, wie ein deutscher Sprecher das +Wort vorlesen würde. **Nicht komplett großschreiben** („EIGEN" würde als „E I Ge E En" +buchstabiert). + +> **Warum Umschrift und nicht Lautschrift (IPA)?** Chatterbox liest Buchstaben, keine +> Laute. Ein IPA-Eingang existiert nicht — getestet: aus `ɹɪˈpɑzɪˌtɔɹi` wird hörbar +> „Ripacitari". Deshalb schreibt man die Aussprache mit deutschen Buchstaben auf. --- diff --git a/README.md b/README.md index a9e8868..4d5f49a 100644 --- a/README.md +++ b/README.md @@ -458,8 +458,66 @@ python chatterbox_cli_v4.py --lang de \ --input nachricht.txt ``` -Häufige Begriffe sind bereits eingebaut (GitHub, YouTube, iPhone, Xi Jinping u. a.). -Das eigene Dict wird immer **nach** dem eingebauten angewendet — Überschreibungen sind möglich. +### Ladereihenfolge + +Das Wörterbuch liegt in **`pronunciation.py`** — einem eigenständigen Modul **ohne +torch-Abhängigkeit**, damit jede Chatterbox-Anwendung (CLI, HTTP-Service, MCP-Adapter, +fremde Projekte) es nutzen kann, ohne ein 4-GB-Modell zu laden: + +```python +from pronunciation import load_pronunciation_dict, apply_pronunciation_dict +text = apply_pronunciation_dict(text, load_pronunciation_dict("de")) +``` + +Quellen werden zusammengeführt, spätere überschreiben frühere: + +| # | Quelle | Zweck | +|---|---|---| +| 1 | `DEFAULT_PRONUNCIATION_DE` (in `pronunciation.py`) | Notnagel, falls keine Datei da ist | +| 2 | `pronunciation/.json` | **die gepflegte Liste** (~200 Einträge), versioniert | +| 3 | `~/.config/chatterbox/pronunciation/.json` | persönliche Ergänzungen | +| 4 | `$CHATTERBOX_PRONUNCIATION_DICT` (Pfad, `:`-getrennt) | pro Projekt/Deployment | +| 5 | `--pronunciation-dict` bzw. API-Parameter | pro Aufruf | + +Inhalt anzeigen: `python pronunciation.py de` + +**Nach einer Änderung am Wörterbuch müssen die Dienste neu gestartet werden** — sie halten es +pro Sprache im Speicher (`_cache` in `pronunciation.py`). Die CLI liest bei jedem Aufruf neu. + +```bash +systemctl --user restart chatterbox-tts # HTTP-Service / MCP-Adapter +systemctl --user restart open-notebook-tts # Open-Notebook-Wrapper, falls installiert +``` + +### Kandidaten finden + +`pronunciation_candidates.py` sucht in einem Text alles, was **hunspell** (deutsches +Wörterbuch) nicht kennt und noch nicht im Wörterbuch steht — praktisch immer genau die +englischen Fachbegriffe und fremden Eigennamen: + +```bash +python pronunciation_candidates.py transkript.txt # nach Häufigkeit sortiert +python pronunciation_candidates.py *.txt --min-count 2 --json # Gerüst zum Ausfüllen +``` + +Braucht `hunspell` + `hunspell-de-de`. Die Umschrift schreibt ein Mensch. + +### Ersetzt wird an Wortgrenzen + +Früher ein nacktes `str.replace()`: aus „UNESCO" wurde „Uh EnnESCO", aus „UNIX" ein +„Uh EnnIX". Jetzt wird nur an Wortgrenzen ersetzt, alles in einem Durchgang (längste +Einträge zuerst), sodass Ersetzungen sich nicht gegenseitig treffen. + +**Umschrift nie komplett großschreiben:** die Akronym-Expansion läuft danach und würde +„EIGEN" zu „E I Ge E En" buchstabieren. + +> **Warum keine Lautschrift (IPA)?** Chatterbox ist graphem-basiert; der multilinguale +> Tokenizer heißt wörtlich `grapheme_mtl_merged_expanded_v1`. Im Vokabular stehen zwar +> IPA-Zeichen und ein `[ipa]`-Sprachtoken, aber empirisch spricht das Modell IPA **nicht** +> als Lautschrift: `ɹɪˈpɑzɪˌtɔɹi` klingt wie „Ripacitari", `dɪˈplɔɪmənt` wie „Deep Romant". +> Auch der Weg über Sprachwechsel im Satz (`[en]…[/en]`) hilft hier nicht: die Segmente +> werden ohne Überblendung aneinandergehängt, jede Naht kostet 0,5–0,8 s Pause. Deshalb +> Umschrift. --- diff --git a/chatterbox_cli_v4.py b/chatterbox_cli_v4.py index 6072fdb..7a6fde3 100755 --- a/chatterbox_cli_v4.py +++ b/chatterbox_cli_v4.py @@ -2,6 +2,7 @@ import argparse import importlib.util +import json import queue import re import sys @@ -187,47 +188,15 @@ UNIT_REPLACEMENTS = { "%": "Prozent", } -# 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] = { - # Chinesische Eigennamen - "Xi Jinping": "Schi Jinping", - "Xi": "Schi", - "Jinping": "Jinping", - "Peking": "Peking", - # Tech-Markennamen mit problematischer Aussprache - "GitHub": "Git Hab", - "LinkedIn": "Linked In", - "YouTube": "Jutjub", - "Wi-Fi": "Wai Fai", - "iPhone": "Aiphone", - "MacBook": "Mäk Buk", - "ChatGPT": "Tschet Dschie Pie Tie", - "OpenAI": "Open A I", - "Anthropic": "Enthropik", - "Claude": "Kloode", - # KI-Begriffe - "GPT": "Dschie Pie Tie", - "LLM": "El El Em", - # Sonstige - "UN": "Uh En", - "ARD": "Ah Er Dee", - "ZDF": "Tset De Eff", - "RTL": "Er Te El", - "AFD": "Ah Eff Dee", - "AfD": "Ah Eff Dee", - "CDU": "Tse De Uh", - "SPD": "Es Pe Dee", - "FDP": "Eff De Pee", - "BSW": "Be Es Wee", -} - - -def apply_pronunciation_dict(text: str, pron_dict: dict[str, str]) -> str: - for phrase, replacement in sorted(pron_dict.items(), key=lambda x: len(x[0]), reverse=True): - text = text.replace(phrase, replacement) - return text +# Aussprache-Wörterbuch: liegt in pronunciation.py (ohne torch-Abhängigkeit, damit +# es auch schlanke Werkzeuge und fremde Anwendungen nutzen können). Die Namen werden +# hier re-exportiert, damit bestehende Importe von chatterbox_cli_v4 weiter greifen. +from pronunciation import ( # noqa: E402 + DEFAULT_PRONUNCIATION_DE, + PRONUNCIATION_ENV, + apply_pronunciation_dict, + load_pronunciation_dict, +) import unicodedata as _unicodedata @@ -591,11 +560,12 @@ def preprocess_tts_text( if acronym_mode is None: acronym_mode = "german" if lang == "de" else "period_space" - # 1. Aussprache-Wörterbuch zuerst (vor Akronym-Expansion, damit Eigennamen greifen) - if lang == "de": - text = apply_pronunciation_dict(text, DEFAULT_PRONUNCIATION_DE) - if pronunciation_dict: - text = apply_pronunciation_dict(text, pronunciation_dict) + # 1. Aussprache-Wörterbuch zuerst (vor Akronym-Expansion, damit Eigennamen greifen). + # load_pronunciation_dict() fuegt eingebaute, mitgelieferte, benutzereigene und + # per Umgebungsvariable gesetzte Eintraege zusammen; `pronunciation_dict` (vom + # Aufrufer) hat Vorrang. In EINEM Durchgang ersetzen, damit eine Ersetzung nicht + # erneut von einem anderen Eintrag getroffen wird. + text = apply_pronunciation_dict(text, load_pronunciation_dict(lang, pronunciation_dict)) if normalize_units_values: text = normalize_units(text, lang) diff --git a/pronunciation.py b/pronunciation.py new file mode 100644 index 0000000..29948fb --- /dev/null +++ b/pronunciation.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Aussprache-Woerterbuecher fuer Chatterbox — fuer JEDE Chatterbox-Anwendung nutzbar. + +Bewusst ohne torch/chatterbox-Import: reine Standardbibliothek. Damit koennen auch +schlanke Werkzeuge (z.B. pronunciation_candidates.py) und fremde Anwendungen das +Woerterbuch verwenden, ohne ein 4-GB-Modell zu laden. + + from pronunciation import load_pronunciation_dict, apply_pronunciation_dict + text = apply_pronunciation_dict(text, load_pronunciation_dict("de")) + +WARUM UMSCHRIFT UND NICHT LAUTSCHRIFT: Chatterbox ist graphem-basiert. Der +multilinguale Tokenizer heisst woertlich "grapheme_mtl_merged_expanded_v1" und hat +keinen Phonem-Eingang. Im Vokabular stehen zwar IPA-Zeichen und sogar ein [ipa]- +Sprachtoken — empirisch getestet spricht das Modell IPA aber NICHT als Lautschrift: +aus "ɹɪˈpɑzɪˌtɔɹi" wird hoerbar "Ripacitari", aus "dɪˈplɔɪmənt" wird "Deep Romant". +Deshalb: Umschrift in deutscher Rechtschreibung ("Repository" -> "Ripositori"). +""" +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path +from typing import Optional + +# Eingebaute phonetische Annäherungen (Notnagel, falls keine JSON-Datei vorliegt). +# Gepflegt wird das Wörterbuch in pronunciation/de.json — dort gehören neue Einträge hin. +DEFAULT_PRONUNCIATION_DE: dict[str, str] = { + # Chinesische Eigennamen + "Xi Jinping": "Schi Jinping", + "Xi": "Schi", + "Jinping": "Jinping", + "Peking": "Peking", + # Tech-Markennamen mit problematischer Aussprache + "GitHub": "Git Hab", + "LinkedIn": "Linked In", + "YouTube": "Jutjub", + "Wi-Fi": "Wai Fai", + "iPhone": "Aiphone", + "MacBook": "Mäk Buk", + "ChatGPT": "Tschet Dschie Pie Tie", + "OpenAI": "Open A I", + "Anthropic": "Enthropik", + "Claude": "Kloode", + # KI-Begriffe + "GPT": "Dschie Pie Tie", + "LLM": "El El Em", + # Sonstige + "UN": "Uh Enn", + "ARD": "Ah Er Dee", + "ZDF": "Tset De Eff", + "RTL": "Er Te El", + "AFD": "Ah Eff Dee", + "AfD": "Ah Eff Dee", + "CDU": "Tse De Uh", + "SPD": "Es Pe Dee", + "FDP": "Eff De Pee", + "BSW": "Be Es Wee", +} + +# Mitgeliefert (versioniert) neben diesem Modul; benutzereigen unter ~/.config. +PRONUNCIATION_DIR = Path(__file__).resolve().parent / "pronunciation" +USER_PRONUNCIATION_DIR = Path.home() / ".config" / "chatterbox" / "pronunciation" +PRONUNCIATION_ENV = "CHATTERBOX_PRONUNCIATION_DICT" + +_cache: dict[str, dict[str, str]] = {} + + +def _read_file(path: Path) -> dict[str, str]: + """Liest eine Wörterbuch-JSON. Schlüssel mit führendem _ sind Kommentare.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as e: # eine kaputte Datei darf die Synthese nicht verhindern + print(f"[Aussprache] {path} ignoriert ({type(e).__name__}: {e})", file=sys.stderr) + return {} + if not isinstance(data, dict): + print(f"[Aussprache] {path} ignoriert (kein JSON-Objekt)", file=sys.stderr) + return {} + return {k: v for k, v in data.items() if not k.startswith("_") and isinstance(v, str)} + + +def load_pronunciation_dict(lang: str, extra: Optional[dict[str, str]] = None) -> dict[str, str]: + """Baut das Wörterbuch einer Sprache aus allen Quellen zusammen. + + Reihenfolge — spätere überschreiben frühere: + + 1. eingebaut DEFAULT_PRONUNCIATION_DE (nur de) + 2. mitgeliefert /pronunciation/.json + 3. benutzereigen ~/.config/chatterbox/pronunciation/.json + 4. Umgebung $CHATTERBOX_PRONUNCIATION_DICT (Pfad; mehrere mit ':' trennen) + 5. explizit `extra` (z.B. --pronunciation-dict oder ein API-Parameter) + + So teilen sich alle Chatterbox-Anwendungen dieselbe gepflegte Basis, und jede + kann trotzdem eigene Begriffe ergänzen. Zwischengespeichert wird pro Sprache + (ohne `extra`). + """ + if lang not in _cache: + merged: dict[str, str] = {} + if lang == "de": + merged.update(DEFAULT_PRONUNCIATION_DE) + for path in (PRONUNCIATION_DIR / f"{lang}.json", + USER_PRONUNCIATION_DIR / f"{lang}.json"): + if path.is_file(): + merged.update(_read_file(path)) + for raw in os.environ.get(PRONUNCIATION_ENV, "").split(":"): + if raw.strip(): + p = Path(raw.strip()).expanduser() + if p.is_file(): + merged.update(_read_file(p)) + else: + print(f"[Aussprache] {PRONUNCIATION_ENV}: {p} nicht gefunden", + file=sys.stderr) + _cache[lang] = merged + + result = dict(_cache[lang]) + if extra: + result.update(extra) + return result + + +def _pattern(pron_dict: dict[str, str]) -> re.Pattern: + """Ein Muster für alle Einträge, längste Begriffe zuerst. + + Wortgrenzen sind der Punkt: ein nacktes str.replace() machte aus "UNESCO" ein + "Uh EnnESCO" und aus "UNIX" ein "Uh EnnIX", weil der Eintrag "UN" auch mitten im + Wort traf. Statt \\b (das bei Einträgen versagt, die auf ein Nicht-Wortzeichen + enden, etwa "Wi-Fi") prüfen wir explizit, dass links und rechts kein Wortzeichen + steht. + """ + alternation = "|".join(re.escape(p) for p in sorted(pron_dict, key=len, reverse=True)) + return re.compile(rf"(? str: + """Ersetzt alle Einträge in EINEM Durchgang. + + Ein Durchgang, weil sonst eine Ersetzung Text erzeugen könnte, den ein späterer + Eintrag erneut trifft. + """ + if not pron_dict: + return text + return _pattern(pron_dict).sub(lambda m: pron_dict[m.group(0)], text) + + +if __name__ == "__main__": # kleine Selbstauskunft: python pronunciation.py [lang] + lang = sys.argv[1] if len(sys.argv) > 1 else "de" + d = load_pronunciation_dict(lang) + print(f"{len(d)} Einträge für '{lang}'") + for k in sorted(d): + print(f" {k:24s} -> {d[k]}") diff --git a/pronunciation/de.json b/pronunciation/de.json new file mode 100644 index 0000000..94cb089 --- /dev/null +++ b/pronunciation/de.json @@ -0,0 +1,203 @@ +{ + "_comment": [ + "Aussprache-Woerterbuch fuer deutsche Chatterbox-Synthese.", + "", + "Chatterbox ist GRAPHEM-basiert: es gibt keinen Phonem-/IPA-Eingang (das im", + "Vokabular vorhandene [ipa]-Token wurde getestet und spricht Lautschrift NICHT", + "korrekt aus). Der einzig tragfaehige Weg ist deshalb die Umschrift in deutscher", + "Rechtschreibung: 'Repository' -> 'Ripositori'.", + "", + "Regeln fuer neue Eintraege:", + " - Nur aufnehmen, was die deutsche Synthese WIRKLICH falsch spricht. Anglizismen", + " wie 'Cloud', 'Update', 'Meeting' klingen ohnehin brauchbar -> kein Eintrag.", + " - Umschrift so schreiben, wie ein deutscher Sprecher das Wort vorlesen wuerde.", + " - Ersetzt wird nur an Wortgrenzen, 'UN' trifft also nicht mehr in 'UNESCO'.", + " - Reihenfolge egal: laengere Eintraege gewinnen automatisch vor kuerzeren.", + " - Die Umschrift NICHT komplett grossschreiben: nach dem Woerterbuch laeuft die", + " Akronym-Expansion, die 'EIGEN' zu 'E I Ge E En' buchstabieren wuerde.", + "", + "Kandidaten aus eigenen Texten finden:", + " python pronunciation_candidates.py transkript.txt", + "", + "Schluessel, die mit _ beginnen, werden ignoriert (Kommentare)." + ], + + "Repository": "Ripositori", + "Repositories": "Ripositoris", + "Deployment": "Diploiment", + "Framework": "Fräimwörk", + "Feature": "Fietscher", + "Features": "Fietschers", + "Release": "Rilies", + "Pipeline": "Paiplain", + "Commit": "Kommit", + "Branch": "Bräntsch", + "Issue": "Ischju", + "Pull Request": "Pull Rikwest", + "Open Source": "Open Sors", + "Bug": "Bagg", + "Debugging": "Dibagging", + "Runtime": "Rantaim", + "Design": "Disain", + "Interface": "Interfäis", + "Layout": "Lejaut", + "Cache": "Käsch", + "Queue": "Kju", + "Container": "Kontäiner", + "Cluster": "Klaster", + "Node": "Noud", + "Client": "Klaient", + "Router": "Rauter", + "Firewall": "Fajerwoll", + "Proxy": "Proxi", + "Token": "Touken", + "Prompt": "Prompt", + "Prompting": "Prompting", + "Embedding": "Embedding", + "Inference": "Inferens", + "Fine-Tuning": "Fain Tjuning", + "Machine Learning": "Maschien Lörning", + "Deep Learning": "Diep Lörning", + "Reasoning": "Riesoning", + "Agent": "Ägent", + "Agents": "Ägents", + "Workflow": "Wörkflou", + "Workflows": "Wörkflous", + "Dashboard": "Däschbord", + "Benchmark": "Bentschmark", + "Benchmarks": "Bentschmarks", + "Voice Cloning": "Weus Klouning", + "Speech-to-Text": "Spietsch tu Text", + "Text-to-Speech": "Text tu Spietsch", + + "Donald Trump": "Donald Tramp", + "Trump": "Tramp", + "Elon Musk": "Ihlon Mask", + "Musk": "Mask", + "Joe Biden": "Dschou Baiden", + "Biden": "Baiden", + "Barack Obama": "Barack Obama", + "Sam Altman": "Säm Altmen", + "Jensen Huang": "Dschensen Huang", + "Sundar Pichai": "Sundar Pitschai", + "Satya Nadella": "Satja Nadella", + "Mark Zuckerberg": "Mark Zuckerberg", + "Geoffrey Hinton": "Dschefri Hinton", + "Yann LeCun": "Jann Lököng", + "Andrej Karpathy": "Andrej Karpathi", + + "Hugging Face": "Hagging Fäis", + "Nvidia": "Enwidia", + "NVIDIA": "Enwidia", + "Microsoft": "Maikrosoft", + "Google": "Gugel", + "Amazon": "Ämazon", + "Apple": "Äppel", + "Meta": "Meta", + "Tesla": "Tesla", + "Netflix": "Netflix", + "Adobe": "Adoubi", + "Oracle": "Orakel", + "Salesforce": "Säilsfors", + "Spotify": "Spotifai", + "Reddit": "Reddit", + "Discord": "Diskord", + "Slack": "Släck", + "Twitch": "Twitsch", + "Airbnb": "Ärbienbie", + "Uber": "Uber", + "PayPal": "Päipäl", + "Docker": "Docker", + "Kubernetes": "Kubernetis", + "Linux": "Linux", + "Ubuntu": "Ubuntu", + "Debian": "Debian", + "Windows": "Windous", + "macOS": "Mäck Ou Es", + "Android": "Android", + "Firefox": "Fajerfox", + "Chrome": "Kroum", + "Safari": "Safari", + "Edge": "Edsch", + "Visual Studio Code": "Wischuel Studio Koud", + "Python": "Paiton", + "JavaScript": "Dschawaskript", + "TypeScript": "Taipskript", + "Rust": "Rast", + "Go": "Go", + "Java": "Dschawa", + "Ruby": "Rubi", + "Swift": "Swift", + "Kotlin": "Kotlin", + + "Gemini": "Dschemini", + "Copilot": "Kopailot", + "Midjourney": "Middschörni", + "Stable Diffusion": "Stäibel Diffjuschen", + "Whisper": "Wisper", + "Ollama": "Ollama", + "OpenRouter": "Open Rauter", + "Perplexity": "Pörpleksiti", + "Mistral": "Mistral", + "Cohere": "Kohier", + "DeepSeek": "Dieb Siek", + "Qwen": "Kwen", + "Llama": "Lama", + "Grok": "Grok", + + "Podcast": "Podkäst", + "Podcasts": "Podkästs", + "Streaming": "Strieming", + "Newsletter": "Njuusletter", + "Interview": "Interwjuu", + "Highlight": "Hailait", + "Highlights": "Hailaits", + "Roadmap": "Roudmäp", + "Deadline": "Dedlain", + "Blueprint": "Bluprint", + "Insight": "Insait", + "Insights": "Insaits", + "Mindset": "Maindset", + "Startup": "Startapp", + "Startups": "Startapps", + "Venture Capital": "Wentscher Käpitel", + "Founder": "Faunder", + + "Guardian": "Gardien", + "New York Times": "Njuu Jork Taims", + "Washington Post": "Woschington Poust", + "Financial Times": "Fainänschel Taims", + "Wall Street Journal": "Woll Striet Dschörnel", + "BBC": "Bie Bie Sie", + "CNN": "Sie En En", + "NASA": "Nasa", + "FBI": "Eff Bie Ai", + "CIA": "Sie Ai Äi", + "NATO": "Nato", + "EU": "Eh Uh", + "USA": "Uh Es Ah", + "UK": "Juu Käi", + "AI": "Äi Ai", + "API": "Äi Pie Ai", + "APIs": "Äi Pie Ais", + "CPU": "Zeh Peh Uh", + "GPU": "Geh Peh Uh", + "GPUs": "Geh Peh Uhs", + "RAM": "Ramm", + "SSD": "Es Es Deh", + "URL": "Uh Er El", + "HTTP": "Hah Teh Teh Peh", + "JSON": "Dschäison", + "YAML": "Jämmel", + "SQL": "Es Ku El", + "CLI": "Zieh El Aih", + "IDE": "Aih Dieh Ieh", + "SDK": "Es Dieh Käih", + "RAG": "Rägg", + "MCP": "Em Zieh Pieh", + "TTS": "Tieh Tieh Es", + "STT": "Es Tieh Tieh", + "OCR": "Oh Zeh Er", + "VPN": "Wieh Pieh En", + "DSGVO": "Deh Es Geh Fau Oh" +} diff --git a/pronunciation_candidates.py b/pronunciation_candidates.py new file mode 100755 index 0000000..38418d6 --- /dev/null +++ b/pronunciation_candidates.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Findet Woerter, die im deutschen TTS vermutlich falsch ausgesprochen werden. + +Idee: Was ein deutsches Rechtschreibwoerterbuch (hunspell) nicht kennt, ist meist +ein englischer Fachbegriff oder ein fremder Eigenname — also genau das, was +Chatterbox auf Deutsch falsch liest. Bereits im Aussprache-Woerterbuch stehende +Begriffe werden ausgeblendet. + +Das Ergebnis ist eine VORSCHLAGSLISTE, kein fertiges Woerterbuch: die Umschrift +muss ein Mensch schreiben (oder pruefen). Genau dafuer gibt --json ein Geruest aus, +das man in pronunciation/de.json einfuegen und ausfuellen kann. + + python pronunciation_candidates.py transkript.txt + python pronunciation_candidates.py *.txt --min-count 2 + cat text.md | python pronunciation_candidates.py - --json + +Braucht: hunspell + deutsches Woerterbuch (Debian/Ubuntu: hunspell hunspell-de-de). +""" +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +from collections import Counter +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pronunciation import load_pronunciation_dict # noqa: E402 (leicht, kein torch) + +# Woerter mit Buchstaben, optional Bindestrich/Apostroph im Inneren. +WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß'-]*") + +# Kurze Grossbuchstabenfolgen (API, GPU) kennt hunspell auch nicht — die sind aber +# oft schon als Akronym korrekt buchstabiert. Trotzdem anzeigen, nur markieren. +ACRONYM_RE = re.compile(r"^[A-ZÄÖÜ]{2,6}s?$") + +DICT_CANDIDATES = ["de_DE", "de_DE_frami", "de_AT", "de_CH"] + + +def pick_dictionary() -> str: + for d in DICT_CANDIDATES: + probe = subprocess.run(["hunspell", "-d", d, "-l"], input="Test\n", + capture_output=True, text=True) + if probe.returncode == 0: + return d + sys.exit("FEHLER: kein deutsches hunspell-Woerterbuch gefunden " + "(Debian/Ubuntu: sudo apt install hunspell hunspell-de-de).") + + +def unknown_words(words: list[str], dictionary: str) -> set[str]: + """hunspell -l gibt genau die Woerter zurueck, die es NICHT kennt.""" + out = subprocess.run(["hunspell", "-d", dictionary, "-l"], + input="\n".join(words), capture_output=True, text=True) + return set(out.stdout.split()) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("files", nargs="+", help="Textdateien ('-' = stdin)") + ap.add_argument("--min-count", type=int, default=1, metavar="N", + help="nur Woerter, die mindestens N-mal vorkommen (Default: 1)") + ap.add_argument("--lang", default="de", help="Sprache des Woerterbuchs (Default: de)") + ap.add_argument("--json", action="store_true", + help="Geruest fuer pronunciation/.json ausgeben (Umschrift leer)") + args = ap.parse_args() + + if not shutil.which("hunspell"): + return int(bool(sys.stderr.write( + "FEHLER: hunspell nicht installiert (sudo apt install hunspell hunspell-de-de).\n"))) + + text = "\n".join(sys.stdin.read() if f == "-" else Path(f).read_text(encoding="utf-8") + for f in args.files) + + counts = Counter(WORD_RE.findall(text)) + known = load_pronunciation_dict(args.lang) + # Auch Mehrwort-Eintraege beruecksichtigen ("Donald Trump" deckt "Trump" mit ab). + covered = {w for phrase in known for w in WORD_RE.findall(phrase)} + + dictionary = pick_dictionary() + unknown = unknown_words(sorted(counts), dictionary) + + hits = [(w, n) for w, n in counts.most_common() + if w in unknown and w not in covered and n >= args.min_count] + + if not hits: + print("Keine neuen Kandidaten gefunden.") + return 0 + + if args.json: + print(json.dumps({w: "" for w, _ in hits}, ensure_ascii=False, indent=2)) + print("\n// Umschrift eintragen und nach pronunciation/" + f"{args.lang}.json uebernehmen.", file=sys.stderr) + return 0 + + print(f"Woerterbuch: {dictionary} | bereits abgedeckt: {len(known)} Eintraege") + print(f"Kandidaten ({len(hits)}), haeufigste zuerst:\n") + for w, n in hits: + mark = " (Akronym)" if ACRONYM_RE.match(w) else "" + print(f" {n:4d}x {w}{mark}") + print("\nMit --json ein Geruest zum Ausfuellen erzeugen.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tts_service.py b/tts_service.py index f71abc8..d073314 100644 --- a/tts_service.py +++ b/tts_service.py @@ -99,6 +99,7 @@ class SpeakJob: pronunciation_dict: Optional[dict] session_id: Optional[str] keep_audio: bool = False + no_playback: bool = False # nur WAV erzeugen, NICHT lokal abspielen (Remote-/Gateway-Nutzung) status: JobStatus = field(default=JobStatus.pending) text_preview: str = field(default="") chunks_total: int = 0 @@ -144,13 +145,16 @@ def _worker() -> None: job.chunks_total = len(chunks) job.text_preview = job.text[:80] - playback = tts.PlaybackWorker( - sample_rate=sr, - device=job.audio_device or "pulse", - speed=job.speed, - stop_event=tts.STOP_REQUESTED, - ) - playback.start() + # no_playback: nur generieren (kein lokales Abspielen) -> Remote/Gateway. + playback = None + if not job.no_playback: + playback = tts.PlaybackWorker( + sample_rate=sr, + device=job.audio_device or "pulse", + speed=job.speed, + stop_event=tts.STOP_REQUESTED, + ) + playback.start() wavs: list[torch.Tensor] = [] try: @@ -159,10 +163,12 @@ def _worker() -> None: break wav = tts.generate_chunk(model, model_kind, chunk, job.lang, job.voice) wavs.append(wav) - playback.put(wav) + if playback is not None: + playback.put(wav) job.chunks_done += 1 finally: - playback.stop() + if playback is not None: + playback.stop() if wavs: final = wavs[0] if len(wavs) == 1 else torch.cat(wavs, dim=-1) @@ -215,6 +221,7 @@ class SpeakRequest(BaseModel): session_id: Optional[str] = None pronunciation_dict: Optional[dict] = None keep_audio: bool = False # WAV im Cache behalten für GET /audio/{job_id} + no_playback: bool = False # nur generieren (kein lokales Abspielen) -> fuer Remote/Gateway def _job_to_dict(j: SpeakJob) -> dict: @@ -284,6 +291,7 @@ def speak(req: SpeakRequest): pronunciation_dict=req.pronunciation_dict, session_id=req.session_id, keep_audio=req.keep_audio, + no_playback=req.no_playback, ) _job_queue.put(job)