feat(tts): Aussprache-Normalisierung vor Piper (Ordinalia/Einheiten/Abk./Lexikon)

In-Process-Layer ausgebaut statt neuer Lib/CLI. Leitprinzip: nicht duplizieren,
was espeak-ng schon kann (Kardinal-/Dezimalzahlen bleiben unangetastet) -- nur die
belegten Luecken fuellen.

- german_numbers.py: deutsche Ordinalzahlen 1.-31. (attributiv/adverbial)
- tts_normalizer.py: Ordinalia (Datum '1. Mai'->'erster Mai', Folgen '1. 2. 3.'->
  'erstens, zweitens, ...'), Einheiten nach Zahl (kg/km/km-h/...), Abkuerzungen
  (Dr./z.B./usw.), optionales YAML-Lexikon (config/pronunciation.<lang>.yaml).
  Provider-abhaengige Stufen auto|full|light|off (TTS_NORMALIZE_LEVEL): piper=full,
  Cloud=light (laesst Zahlen/Abk. fuer das Cloud-Modell in Ruhe).
- spoken_response_adapter.py: nummerierte Listen -> Ordinalwoerter statt Loeschen.
- sentence_chunker.py: trennt nicht mehr nach Ziffer+Punkt, Einzelbuchstabe+Punkt
  ('z. B.', Initialen) oder bekannten Abkuerzungen -> behebt das Streaming-Symptom
  ('1.' wurde als eigener 'Satz' zu 'eins').
- orchestrator/dependencies: normalize_level durchgereicht (auto: piper->full).
- Tests: tests/test_tts_normalizer.py + Chunker-Faelle (85 gruen).
- Doku: BEDIENUNGSANLEITUNG (Aussprache verbessern), .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-17 23:01:41 +02:00
commit c9d8552ec7
13 changed files with 411 additions and 56 deletions

View file

@ -20,6 +20,15 @@ def test_sentence_chunker_incremental():
assert SentenceChunker().feed("Eins. Zwei! Drei? Vier") == ["Eins.", "Zwei!", "Drei?"]
def test_sentence_chunker_keeps_ordinals_and_abbreviations():
# "1." (Ziffer+Punkt) darf KEINE Satzgrenze sein.
ch = SentenceChunker()
assert ch.feed("Am 1. Mai ist frei. ") == ["Am 1. Mai ist frei."]
# Abkuerzung "z. B." darf den Satz nicht zerschneiden.
ch2 = SentenceChunker()
assert ch2.feed("Obst, z. B. Äpfel und Birnen. ") == ["Obst, z. B. Äpfel und Birnen."]
def test_sse_delta_parsing():
assert sse_delta('data: {"choices":[{"delta":{"content":"Hal"}}]}') == "Hal"
assert sse_delta("data: [DONE]") is None

View file

@ -0,0 +1,89 @@
"""Tests fuer die TTS-Text-Normalisierung (Aussprache vor Piper)."""
import asyncio
from app.pipeline.tts_normalizer import TTSNormalizer
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
from app.pipeline import german_numbers as gn
def _run(coro):
return asyncio.run(coro)
def _norm(text, level="full", language="de"):
return _run(TTSNormalizer().run(text, language=language, level=level))
# --- Ordinalzahlen ---------------------------------------------------------
def test_date_ordinal_to_attributive():
assert "erster Mai" in _norm("Am 1. Mai feiern wir.")
assert "dritter Oktober" in _norm("Der 3. Oktober ist ein Feiertag.")
def test_enumeration_run_to_adverbial():
out = _norm("1. 2. 3. fertig")
assert "erstens" in out and "zweitens" in out and "drittens" in out
def test_enum_marker_paren():
assert "erstens" in _norm("1) Milch")
assert "zweitens" in _norm("2.) Eier")
def test_plain_trailing_number_not_touched():
# Eine einzelne Zahl am Satzende ist KEINE Ordinalzahl -> bleibt Zahl.
out = _norm("Ich nehme 5.")
assert "fünftens" not in out and "fünfter" not in out
assert "5" in out
# --- Einheiten -------------------------------------------------------------
def test_units_after_number():
assert "Kilogramm" in _norm("Das wiegt 10 kg.")
assert "Kilometer pro Stunde" in _norm("Tempo 50 km/h.")
def test_unit_letter_not_triggered_without_number():
# "m" ohne vorangehende Zahl darf nicht zu "Meter" werden.
assert "Meter" not in _norm("Am Morgen.")
# --- Abkuerzungen / Lexikon ------------------------------------------------
def test_builtin_abbreviations():
assert "Doktor" in _norm("Dr. Müller kommt.")
assert "und so weiter" in _norm("Brot, Milch usw.")
# --- Stufen ----------------------------------------------------------------
def test_light_leaves_numbers_and_abbrev_for_cloud():
out = _norm("Am 1. Mai wiegt es 10 kg, Dr. Müller.", level="light")
assert "erster" not in out and "Kilogramm" not in out and "Doktor" not in out
assert "1." in out and "kg" in out # Cloud-TTS macht das selbst
def test_off_returns_unchanged():
src = "Am 1. Mai, 10 kg."
assert _norm(src, level="off") == src
def test_url_and_email_squashed_in_light():
out = _norm("Schau auf https://example.org oder mail@example.org.", level="light")
assert "Link" in out and "E-Mail-Adresse" in out
# --- Zusammenspiel mit dem SpokenResponseAdapter ---------------------------
def test_adapter_numbered_list_to_ordinal():
md = "1. Milch\n2. Eier\n3. Brot"
out = _run(SpokenResponseAdapter().run(md))
assert "erstens, Milch" in out and "zweitens, Eier" in out
def test_german_numbers_table():
assert gn.ordinal_attributive(1) == "erster"
assert gn.ordinal_adverbial(3) == "drittens"
assert gn.has_ordinal(31) and not gn.has_ordinal(32)