09a1c98514
- pocket_server.py (Produktions-TTS mit Stimmen-Waechter), text_norm, Bench-/Diag-Skripte - lucy-f5: f5_server/f5_test/bench_dml (DirectML-Experiment, Phase C/D offen) - .gitignore: venvs/Modelle/Audio/Logs der beiden Ordner + box_recon/gemma_swap-Scratch Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
121 lines
5.5 KiB
Python
121 lines
5.5 KiB
Python
"""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"(?<![\w\x00])(\d+)[.,](\d+)(?![\w\x00])", _dec, text)
|
|
|
|
# 5) Zahl neben ALL-CAPS-Token: Einheit (GB/TB..) -> 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"(?<![\w\x00])\d{1,4}(?![\w\x00])", _c, text)
|
|
|
|
# 7) Masken zurückholen
|
|
text = re.sub(r"\x00(\d+)\x00", lambda m: masks[int(m.group(1))], text)
|
|
return text
|