test: Coverage für Gender-/Stimm-System + hermetische Suite
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 <noreply@anthropic.com>
This commit is contained in:
parent
35c08074cf
commit
3c549a05b1
5 changed files with 280 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
127
tests/test_cartesia_tts.py
Normal file
127
tests/test_cartesia_tts.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue