"""Deutsche Zahlen-/Uhrzeit-Normalisierung für Lucys Stimme (num2words). Eigenständig (nur re + num2words, KEIN torch/pocket_tts-Import), damit schnell testbar und von pocket_server importierbar. Kernidee: Zahlen im Fließtext verbalisieren ("18:01" -> "achtzehn Uhr eins", "16 GB" -> "sechzehn GB", "4.5" -> "vier Komma fünf"), aber Modell-/Versionsnummern SCHÜTZEN ("RX 9070 XT", "Qwen3.6", "H100" bleiben roh — die soll man nicht als Kardinalzahl lesen). Abschaltbar via LUCY_NUM_NORM=0. """ import os import re try: from num2words import num2words as _n2w except Exception: # Lib fehlt -> No-op (Stimme läuft trotzdem) _n2w = None # AUS per Default (2026-07-01, empirisch via Whisper-Rücktranskription verifiziert): Pocket german_24l # spricht ROHE Ziffern ("21 Uhr 40", "16 GB", "4.5 GHz", "9:30") sauber. Vorverbalisierte deutsche # Kompositzahlen ("einundzwanzig") ZERBRICHT das Modell zu Kauderwelsch ("ein Mund", "ein Aus-20"). # Also NICHT verbalisieren. Mit LUCY_NUM_NORM=1 wieder anschaltbar (falls je ein Modell es braucht). NUM_NORM = os.environ.get("LUCY_NUM_NORM", "0") not in ("0", "false", "False") _MONTHS = "Januar Februar März April Mai Juni Juli August September Oktober November Dezember".split() _MONTH_RX = "|".join(_MONTHS) # FULLY-uppercase 2+-Einheiten, neben denen Zahlen VERBALISIERT werden (sechzehn GB) — im Gegensatz # zu Modell-Akronymen (RX/XT/RTX), wo die Ziffern roh bleiben (RX 9070 XT). Gemischt-Case-Einheiten # (GHz/MHz/kg/km/Grad/Prozent) werden von der Akronym-Regel eh nicht erfasst -> dort wird ohnehin verbalisiert. _CAPS_UNITS = {"GB", "TB", "MB", "KB", "PB", "EB", "KW", "MW"} # Garble-Kandidaten (Whisper-verifiziert): Pocket german_24l kann diese Akronyme/Codes NICHT sauber # sprechen ("ROCm"->"AOC HM", "UTC"->Kauderwelsch) -> Ersatz durch gesprochene/buchstabierte Form. # Erweiterbar via LUCY_ACRONYM_EXTRA="ABC=A B C,XYZ=..." (neue Fälle findet transcribe.py). _ACRONYM_FIX = { "ROCm": "Rockem", "UTC": "U T C", "GMT": "G M T", "CEST": "C E S T", "CET": "C E T", "MESZ": "M E S Z", "MEZ": "M E Z", "NVMe": "N V M E", "PCIe": "P C I E", } def _build_acr(): m = dict(_ACRONYM_FIX) for pair in os.environ.get("LUCY_ACRONYM_EXTRA", "").split(","): if "=" in pair: k, v = pair.split("=", 1) if k.strip(): m[k.strip()] = v.strip() return m _ACR_MAP = _build_acr() _ACR_RX = re.compile(r"\b(" + "|".join(re.escape(k) for k in sorted(_ACR_MAP, key=len, reverse=True)) + r")\b") if _ACR_MAP else None ACR_ON = os.environ.get("LUCY_ACRONYM", "1") not in ("0", "false", "False") # A/B-Abschaltung def fix_acronyms(text: str) -> str: """Garble-anfällige Akronyme durch gesprochene Form ersetzen (ROCm -> Rockem, UTC -> U T C).""" return _ACR_RX.sub(lambda m: _ACR_MAP[m.group(0)], text) if (_ACR_RX and ACR_ON) else text def _card(v) -> str: try: return _n2w(int(v), lang="de") except Exception: return str(v) def normalize_numbers(text: str) -> str: if not NUM_NORM or _n2w is None: return text masks: list[str] = [] def _mask(s: str) -> str: masks.append(s) return f"\x00{len(masks) - 1}\x00" # 1) Uhrzeiten: "18:01 Uhr"/"18:01" -> "18 Uhr 01", "18:00" -> "18 Uhr" (Ziffern verbalisiert Schritt 6) text = re.sub(r"\b(\d{1,2}):00(\s*Uhr)?\b", r"\1 Uhr", text) text = re.sub(r"\b(\d{1,2}):(\d{2})\s*Uhr\b", r"\1 Uhr \2", text) text = re.sub(r"\b(\d{1,2}):(\d{2})\b", r"\1 Uhr \2", text) # 2) Datums-Ordinalzahlen: "3. Januar" -> "dritter Januar" def _ord(m): try: return f"{_n2w(int(m.group(1)), to='ordinal', lang='de')} {m.group(2)}" except Exception: return m.group(0) text = re.sub(rf"\b(\d{{1,2}})\.\s+({_MONTH_RX})\b", _ord, text) # 3) Modell-/Versions-Token (Buchstabe UND Ziffer, evtl. mit Punkten: Qwen3.6, H100, v2.1, RTX4090) # -> ganzes Token maskieren (nie verbalisieren). Satz-Endpunkt/-Komma bleibt draußen. def _mask_model(m): tok = m.group(0); trail = "" while tok and tok[-1] in ".,": trail = tok[-1] + trail; tok = tok[:-1] if re.search(r"[A-Za-zÄÖÜäöü]", tok) and re.search(r"\d", tok): return _mask(tok) + trail return m.group(0) text = re.sub(r"[A-Za-zÄÖÜäöü0-9]+(?:[.,][A-Za-zÄÖÜäöü0-9]+)*", _mask_model, text) # 4) Standalone-Dezimalzahl (nur Ziffern, kein Buchstabe): "4.5" -> "vier Komma fünf" # (Nachkommastellen ziffernweise, wie im Deutschen üblich). def _dec(m): return f"{_card(m.group(1))} Komma {' '.join(_card(d) for d in m.group(2))}" text = re.sub(r"(? verbalisieren; Modell-Akronym (RX/XT) -> roh maskieren. def _by_ctx(num, acr): return num if acr.upper() in _CAPS_UNITS else _mask(num) text = re.sub(r"\b([A-ZÄÖÜ]{2,})\s+(\d{1,6})\b", lambda m: f"{m.group(1)} {_by_ctx(m.group(2), m.group(1))}", text) text = re.sub(r"\b(\d{1,6})\s+([A-ZÄÖÜ]{2,})\b", lambda m: f"{_by_ctx(m.group(1), m.group(2))} {m.group(2)}", text) # 6) verbleibende reine Ganzzahlen (0..9999) verbalisieren; größere roh lassen (IDs/Codes) def _c(m): v = int(m.group(0)) return _n2w(v, lang="de") if v <= 9999 else m.group(0) text = re.sub(r"(?