Aussprache-Wörterbuch: eigenes Modul, Wortgrenzen, gepflegte Liste, Kandidaten-Suche

Englische Fachbegriffe und fremde Eigennamen wurden auf Deutsch falsch vorgelesen
("Repository", "Donald Trump"). Chatterbox hat keinen Lautschrift-Eingang, also bleibt
nur die Umschrift in deutscher Rechtschreibung — die gab es zwar schon, aber nur als
kleines Dict im CLI-Skript, das keine andere Anwendung sinnvoll erweitern konnte.

Empirisch geprüft, warum es keinen besseren Weg gibt:
- Der multilinguale Tokenizer heißt "grapheme_mtl_merged_expanded_v1" und enthält zwar
  IPA-Zeichen und ein [ipa]-Sprachtoken. generate() lehnt language_id="ipa" ab; umgeht
  man nur die Whitelist, kommt Unsinn heraus: "ɹɪˈpɑzɪˌtɔɹi" klingt wie "Ripacitari",
  "dɪˈplɔɪmənt" wie "Deep Romant" (per Whisper zurücktranskribiert). IPA ist tot.
- Der vorhandene Sprachwechsel im Satz ([en]...[/en]) synthetisiert die Teile korrekt,
  klebt sie aber ohne Überblendung zusammen: gemessen 4 Nahtpausen von 0,5-0,8 s, der
  Satz wächst von 3,9 s auf 7,1 s. Für Fließtext unbrauchbar.

Neu:
- pronunciation.py — eigenständiges Modul, reine Standardbibliothek (kein torch), damit
  CLI, HTTP-Service, MCP-Adapter und fremde Projekte (Open Notebook) dasselbe Wörterbuch
  nutzen können, ohne das Modell zu laden. Namen werden aus chatterbox_cli_v4
  re-exportiert, bestehende Importe bleiben gültig.
- Ladereihenfolge mit Vorrang: eingebaut -> pronunciation/<lang>.json (mitgeliefert) ->
  ~/.config/chatterbox/pronunciation/<lang>.json -> $CHATTERBOX_PRONUNCIATION_DICT ->
  explizit übergebenes Dict. Kaputte JSON-Dateien werden gemeldet, nicht fatal.
- pronunciation/de.json — kuratierte Einträge (Tech, KI, Firmen, Personen, Medien).
- pronunciation_candidates.py — findet in einem Text alles, was hunspell (de) nicht kennt
  und noch nicht im Wörterbuch steht. An einem echten Podcast-Transkript getestet: 61
  Kandidaten, darunter Voicebox, Scrapling, Scraping.

Fehlerbehebung: Ersetzt wird jetzt an Wortgrenzen und in einem einzigen Durchgang. Das
alte str.replace() machte aus "UNESCO" ein "Uh EnnESCO" und aus "UNIX" ein "Uh EnnIX",
weil der Eintrag "UN" auch mitten im Wort traf.

Verifiziert: 199 Einträge laden in 44 ms ohne torch; UNESCO/UNIX/Xiaomi bleiben intakt;
Open Notebooks TTS-Server spricht die Umschrift ohne eine einzige Codeänderung dort; der
MCP-Service auf :9999 läuft unverändert weiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-07-11 16:52:37 +02:00
commit cd9383ff88
6 changed files with 570 additions and 50 deletions

151
pronunciation.py Normal file
View file

@ -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 <modul>/pronunciation/<lang>.json
3. benutzereigen ~/.config/chatterbox/pronunciation/<lang>.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"(?<!\w)(?:{alternation})(?!\w)")
def apply_pronunciation_dict(text: str, pron_dict: dict[str, str]) -> 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]}")