From 3c549a05b1d3fbd6687c74455a8ff808a993aebe Mon Sep 17 00:00:00 2001 From: dschlueter Date: Thu, 25 Jun 2026 00:32:47 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Coverage=20f=C3=BCr=20Gender-/Stimm-Sys?= =?UTF-8?q?tem=20+=20hermetische=20Suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schließt die Test-Lücken der Gender-/Stimm-Arbeit der letzten Tage: - test_routing.py: voice_for_route über alle Sprachen × Geschlechter (inkl. ES Multi-Speaker #0/#1, "any"→weiblich, Gegengeschlecht-Fallback, AR/PT/ZH Einzelstimmen, unbekannte Sprache → None). - test_piper_tts.py: _parse_speaker (#id) + Gender-Code-Auflösung in synthesize() + speaker_id-Durchreichung für Multi-Speaker. - test_cartesia_tts.py (neu): Voice-Map-Parsing, _resolve_voice_id-Präzedenz, nicht unterstützte Sprachen, direkte-UUID-Durchreichung, Cartesia→Piper-Fallback. Hermetik-Fix in conftest.py: Deployment-Fallback-Ketten (stt/llm/tts_fallback) werden pro Test auf "" gesetzt. Vorher schlugen 3 WS-Streaming-Tests fehl, weil llm_fallback="openrouter" aus der TOML durchsickerte und Stub-LLMs ohne stream() auf den echten OpenRouter durchfielen ("OPENROUTER_API_KEY is empty"). Ergebnis: 213 passed (vorher 164 passed / 3 failed). DEPLOYMENT.md Teil 4.0 dokumentiert den Pflicht-Testlauf vor jedem Deploy. Co-Authored-By: Claude Sonnet 4.6 --- DEPLOYMENT.md | 21 +++++- tests/conftest.py | 5 ++ tests/test_cartesia_tts.py | 127 +++++++++++++++++++++++++++++++++++++ tests/test_piper_tts.py | 60 ++++++++++++++++++ tests/test_routing.py | 68 ++++++++++++++++++++ 5 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 tests/test_cartesia_tts.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e43ace6..30c56bc 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1032,7 +1032,26 @@ sudo systemctl reload nginx ## Teil 4: Verifikation -> Zuletzt vollständig getestet: 2026-06-24 — Piper alle 10 Sprachen ✓, Cartesia DE/EN/FR/ES/PT/ZH ✓ +> Zuletzt vollständig getestet: 2026-06-25 — pytest 213 passed ✓, Piper alle 10 Sprachen ✓, Cartesia DE/EN/FR/ES/PT/ZH ✓ + +### 4.0 Automatische Test-Suite (PFLICHT vor jedem Deploy) + +Die pytest-Suite läuft offline und deterministisch (keine echten Netz-/API-Aufrufe, +keine Stimm-Modelle nötig). Vor jedem Deploy ausführen — muss **grün** sein: + +```bash +cd /opt/voice-assistant +sudo -u voice .venv/bin/pip install -q -e '.[test]' # einmalig: pytest installieren +sudo -u voice .venv/bin/python -m pytest -q +# Erwartet: "213 passed" +``` + +Abdeckung u. a.: Stimm-/Geschlechts-Routing (`voice_for_route`), Piper Multi-Speaker +(`#speaker_id`) und Gender-Code-Auflösung, Cartesia Voice-Map + nicht unterstützte +Sprachen, Cartesia→Piper-Fallback. Die Fixture in `tests/conftest.py` neutralisiert +die Deployment-Fallback-Ketten (`*_fallback`), damit Tests hermetisch bleiben. + +### 4.1 Manuelle Smoke-Checks ```bash # Service läuft? diff --git a/tests/conftest.py b/tests/conftest.py index ca09f51..db82a0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,11 @@ def reset_state(tmp_path, monkeypatch): monkeypatch.setattr(settings, "admin_api_key", "") # deterministische Event-Reihenfolge in Tests; Stream-Tests setzen es explizit monkeypatch.setattr(settings, "audio_stream_default", False) + # Hermetik: Deployment-Fallback-Ketten duerfen nicht in Tests durchsickern, + # sonst landen Stub-Provider-Aufrufe (ohne stream()) beim echten OpenRouter/lokalen LLM. + monkeypatch.setattr(settings, "stt_fallback", "") + monkeypatch.setattr(settings, "llm_fallback", "") + monkeypatch.setattr(settings, "tts_fallback", "") yield deps._store = None deps._audio_router = None diff --git a/tests/test_cartesia_tts.py b/tests/test_cartesia_tts.py new file mode 100644 index 0000000..8b8e878 --- /dev/null +++ b/tests/test_cartesia_tts.py @@ -0,0 +1,127 @@ +"""Tests fuer den Cartesia-TTS-Provider: Voice-Map, Geschlechts-Aufloesung, +nicht unterstuetzte Sprachen und der Cartesia->Piper-Fallback (HTTP gemockt).""" + +import asyncio + +import httpx +import pytest + +from app.providers.tts.cartesia import CartesiaTTSProvider, _parse_voice_map +from app.providers.fallback import FallbackTTSProvider + +# Eine Zeile pro 'lang:gender:uuid:name'. Kommentare und ungueltige Zeilen testen wir mit. +VOICES = """ +de:m:uuid-de-m:Sebastian +de:f:uuid-de-f:Alina +en:f:uuid-en-f:Gemma +# diese Zeile ist ein Kommentar und wird ignoriert +zh:m:uuid-zh-m:Kai +""" + +REQ = httpx.Request("POST", "https://api.cartesia.ai/tts/bytes") + + +def _run(coro): + return asyncio.run(coro) + + +def _provider(): + return CartesiaTTSProvider("key", "DEFAULT-UUID", "sonic-turbo", 24000, voices_str=VOICES) + + +# --- Voice-Map-Parsing ----------------------------------------------------- + +def test_parse_voice_map_valid_and_comments(): + m = _parse_voice_map(VOICES) + assert m[("de", "m")]["uuid"] == "uuid-de-m" + assert m[("de", "f")]["name"] == "Alina" + assert ("en", "f") in m + assert ("zh", "m") in m + + +def test_parse_voice_map_ignores_invalid_gender(): + assert _parse_voice_map("de:x:uuid:Name") == {} + + +def test_parse_voice_map_ignores_short_lines(): + assert _parse_voice_map("de:m:nur-drei-teile") == {} + + +# --- _resolve_voice_id: Praezedenz ----------------------------------------- + +def test_resolve_exact_gender(): + p = _provider() + assert p._resolve_voice_id("de", "m") == "uuid-de-m" + assert p._resolve_voice_id("de", "f") == "uuid-de-f" + + +def test_resolve_any_prefers_female(): + assert _provider()._resolve_voice_id("de", "any") == "uuid-de-f" + + +def test_resolve_falls_back_to_other_gender(): + # en hat nur eine weibliche Stimme -> Anfrage 'm' faellt auf 'f' zurueck. + assert _provider()._resolve_voice_id("en", "m") == "uuid-en-f" + + +def test_resolve_unknown_language_uses_default(): + assert _provider()._resolve_voice_id("xx", "f") == "DEFAULT-UUID" + + +# --- synthesize (HTTP gemockt) --------------------------------------------- + +def _patch_post(monkeypatch, response): + captured = {} + + async def fake_post(self, url, **kw): + captured["json"] = kw.get("json") + return response + + monkeypatch.setattr(httpx.AsyncClient, "post", fake_post) + return captured + + +def test_synthesize_returns_audio_and_resolves_gender(monkeypatch): + cap = _patch_post(monkeypatch, httpx.Response(200, content=b"PCM", request=REQ)) + out = _run(_provider().synthesize("Hallo", voice="f", language="de")) + assert out == b"PCM" + assert cap["json"]["voice"]["id"] == "uuid-de-f" + + +def test_synthesize_direct_uuid_passthrough(monkeypatch): + cap = _patch_post(monkeypatch, httpx.Response(200, content=b"X", request=REQ)) + _run(_provider().synthesize("Hi", voice="custom-uuid-123", language="de")) + assert cap["json"]["voice"]["id"] == "custom-uuid-123" + + +@pytest.mark.parametrize("lang", ["ar", "it", "ru", "pl"]) +def test_synthesize_unsupported_language_raises(monkeypatch, lang): + _patch_post(monkeypatch, httpx.Response(200, content=b"X", request=REQ)) + with pytest.raises(RuntimeError, match="unterst"): + _run(_provider().synthesize("x", voice="f", language=lang)) + + +def test_synthesize_empty_key_raises(): + p = CartesiaTTSProvider("", "DEF", "sonic-turbo", 24000, voices_str=VOICES) + with pytest.raises(ValueError, match="API_KEY"): + _run(p.synthesize("x", voice="f", language="de")) + + +def test_synthesize_empty_text_raises(): + with pytest.raises(ValueError, match="empty"): + _run(_provider().synthesize(" ", voice="f", language="de")) + + +# --- Cartesia -> Piper Fallback fuer nicht unterstuetzte Sprachen ---------- + +def test_cartesia_to_piper_fallback_for_unsupported_language(): + """Cartesia kann IT nicht -> FallbackTTSProvider reicht den Gender-Code an Piper durch.""" + cartesia = _provider() # wirft RuntimeError fuer it/ar/ru/pl (vor jedem HTTP-Call) + + class PiperStub: + async def synthesize(self, text, voice=None, audio_format="pcm", language=None): + return b"PIPER:" + (voice or "").encode() + + chain = FallbackTTSProvider("tts", [("cartesia", cartesia), ("piper", PiperStub())]) + out = _run(chain.synthesize("Ciao", voice="f", language="it")) + assert out == b"PIPER:f" # Gender-Code unveraendert an Piper weitergereicht diff --git a/tests/test_piper_tts.py b/tests/test_piper_tts.py index 7e841e7..c4a42ea 100644 --- a/tests/test_piper_tts.py +++ b/tests/test_piper_tts.py @@ -82,3 +82,63 @@ def test_piper_resamples_when_rates_differ(tmp_path): provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) pcm = _run(provider.synthesize("Hallo")) assert isinstance(pcm, bytes) and len(pcm) > 0 + + +# --------------------------------------------------------------------------- +# Multi-Speaker (#id) und Geschlechts-Code-Aufloesung in synthesize(). +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("voice,expected", [ + ("es_ES-sharvard-medium#1", ("es_ES-sharvard-medium", 1)), + ("es_ES-sharvard-medium#0", ("es_ES-sharvard-medium", 0)), + ("de_DE-thorsten-high", ("de_DE-thorsten-high", None)), + ("voice#keine-zahl", ("voice#keine-zahl", None)), # ungueltige ID -> kein Split + (None, (None, None)), +]) +def test_parse_speaker(voice, expected): + assert PiperTTSProvider._parse_speaker(voice) == expected + + +def test_synthesize_resolves_gender_code_to_voice(tmp_path, monkeypatch): + # voice="f"/"m" + language -> echter geschlechtsspezifischer Stimmname. + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + + seen = {} + real_mp = PiperTTSProvider._model_paths + + def capture(self, voice): + seen["voice"] = voice + return real_mp(self, "de_DE-test") # auf vorhandenes Fake-Modell umlenken + + monkeypatch.setattr(PiperTTSProvider, "_model_paths", capture) + + _run(provider.synthesize("Hallo", voice="f", language="de")) + assert seen["voice"] == "de_DE-kerstin-low" # f + de -> weiblich + + _run(provider.synthesize("Hallo", voice="m", language="de")) + assert seen["voice"] == "de_DE-thorsten-high" # m + de -> maennlich + + +def test_synthesize_multispeaker_passes_speaker_id(tmp_path, monkeypatch): + # es:f -> sharvard#1, es:m -> sharvard#0; speaker_id muss durchgereicht werden. + binp, voices = _make_fake_voice(tmp_path, sample_rate=24000) + provider = PiperTTSProvider(binp, voices, "de_DE-test", target_rate=24000) + + real_mp = PiperTTSProvider._model_paths + monkeypatch.setattr(PiperTTSProvider, "_model_paths", + lambda self, v: real_mp(self, "de_DE-test")) + + seen = {} + + def fake_sync(self, model, config, text, speaker_id=None): + seen["speaker_id"] = speaker_id + return FAKE_PCM, 24000 + + monkeypatch.setattr(PiperTTSProvider, "_synthesize_sync", fake_sync) + + _run(provider.synthesize("Hola", voice="f", language="es")) + assert seen["speaker_id"] == 1 + + _run(provider.synthesize("Hola", voice="m", language="es")) + assert seen["speaker_id"] == 0 diff --git a/tests/test_routing.py b/tests/test_routing.py index 4eeab76..9a36d98 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -64,3 +64,71 @@ def test_registry_unknown_provider_raises(): def test_registry_known_provider(): assert type(get_tts_provider("piper")).__name__ == "PiperTTSProvider" + + +# --------------------------------------------------------------------------- +# voice_for_route: Stimm-/Geschlechts-Auswahl je Provider und Sprache. +# --------------------------------------------------------------------------- +from app.dependencies import voice_for_route, PIPER_VOICE_GENDERED + + +@pytest.mark.parametrize("lang,gender,expected", [ + ("de", "f", "de_DE-kerstin-low"), + ("de", "m", "de_DE-thorsten-high"), + ("en", "f", "en_US-amy-medium"), + ("en", "m", "en_US-lessac-high"), + ("fr", "f", "fr_FR-siwis-medium"), + ("fr", "m", "fr_FR-tom-medium"), + ("es", "f", "es_ES-sharvard-medium#1"), # Multi-Speaker: #1 = weiblich + ("es", "m", "es_ES-sharvard-medium#0"), # Multi-Speaker: #0 = maennlich + ("it", "f", "it_IT-paola-medium"), + ("it", "m", "it_IT-riccardo-x_low"), + ("ru", "f", "ru_RU-irina-medium"), + ("ru", "m", "ru_RU-ruslan-medium"), + ("pl", "f", "pl_PL-gosia-medium"), + ("pl", "m", "pl_PL-darkman-medium"), +]) +def test_piper_gendered_voice_exact(lang, gender, expected): + assert voice_for_route("piper", lang, gender) == expected + + +@pytest.mark.parametrize("lang,expected", [ + ("ar", "ar_JO-kareem-medium"), + ("pt", "pt_BR-faber-medium"), + ("zh", "zh_CN-huayan-medium"), +]) +def test_piper_single_voice_languages_ignore_gender(lang, expected): + # Diese Sprachen haben keinen Geschlechtspartner -> selbe Stimme fuer m und f. + assert voice_for_route("piper", lang, "f") == expected + assert voice_for_route("piper", lang, "m") == expected + + +def test_piper_any_prefers_female(): + # 'any' (und unbekannte Codes) bevorzugen weiblich, konsistent mit Cartesia. + assert voice_for_route("piper", "de", "any") == "de_DE-kerstin-low" + assert voice_for_route("piper", "fr", "any") == "fr_FR-siwis-medium" + + +def test_piper_unknown_language_returns_none(): + assert voice_for_route("piper", "xx", "f") is None + + +def test_piper_without_language_returns_none(): + assert voice_for_route("piper", None, "f") is None + + +def test_cartesia_passes_gender_code_through(): + # Cartesia loest die UUID selbst auf -> nur der Gender-Code wird durchgereicht. + assert voice_for_route("cartesia", "de", "f") == "f" + assert voice_for_route("cartesia", "de", "any") == "any" + + +def test_other_provider_returns_none(): + assert voice_for_route("openrouter", "de", "f") is None + + +def test_piper_falls_back_to_opposite_gender(monkeypatch): + # Fehlt die angeforderte Richtung, wird die vorhandene Gegenrichtung genommen + # (vor dem gender-agnostischen LANG_TO_PIPER_VOICE-Fallback). + monkeypatch.delitem(PIPER_VOICE_GENDERED, ("de", "m"), raising=False) + assert voice_for_route("piper", "de", "m") == "de_DE-kerstin-low"