mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-12 10:40:33 +00:00
feat(tts): add French (fr-CA, fr-FR) voices to Edge TTS allowlist
Adds 4 fr-CA + 3 fr-FR Edge neural voices so francophone users can use the Edge TTS engine instead of receiving HTTP 400 "invalid voice" on every utterance. Pure superset of the existing allowlist; no validation or rate-limit behavior changes. Tests mirror the test_issue2931 in-process / mocked-edge_tts pattern: each new voice is parametrized through _handle_tts and asserted to reach synthesis (HTTP 200); fr-BE-CharlineNeural (real Edge voice but intentionally unlisted) is asserted to still 400. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -10318,7 +10318,10 @@ def _handle_tts(handler, parsed):
|
||||
allowed = {
|
||||
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
|
||||
"zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
|
||||
"en-US-AriaNeural", "en-US-GuyNeural"
|
||||
"en-US-AriaNeural", "en-US-GuyNeural",
|
||||
"fr-CA-AntoineNeural", "fr-CA-JeanNeural",
|
||||
"fr-CA-SylvieNeural", "fr-CA-ThierryNeural",
|
||||
"fr-FR-DeniseNeural", "fr-FR-EloiseNeural", "fr-FR-HenriNeural",
|
||||
}
|
||||
if voice not in allowed:
|
||||
from api.helpers import bad as _bad
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Coverage for French voices in the Edge TTS allowlist.
|
||||
|
||||
Mirrors the in-process / mocked-edge_tts pattern from
|
||||
test_issue2931_edge_tts_endpoint.py to assert each French voice in the
|
||||
allowlist reaches synthesis (HTTP 200) and that an unlisted French locale
|
||||
(fr-BE) is still rejected at the allowlist (HTTP 400).
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import api.routes as routes
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self, body: bytes, command: str = "POST", headers=None, client="1.2.3.4"):
|
||||
self.command = command
|
||||
self.rfile = io.BytesIO(body)
|
||||
self.wfile = io.BytesIO()
|
||||
self.headers = headers or {}
|
||||
self.headers.setdefault("Content-Length", str(len(body)))
|
||||
self.client_address = (client, 12345)
|
||||
self.status = None
|
||||
self.sent_headers = {}
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.sent_headers[key] = value
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def payload(self):
|
||||
try:
|
||||
return json.loads(self.wfile.getvalue().decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _post(body_dict, **kw):
|
||||
body = json.dumps(body_dict).encode()
|
||||
return _FakeHandler(body, **kw)
|
||||
|
||||
|
||||
def _reset_limiter():
|
||||
if hasattr(routes._handle_tts, "_tts_limiter"):
|
||||
del routes._handle_tts._tts_limiter
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_tts_limiter(monkeypatch):
|
||||
import api.auth as _auth
|
||||
monkeypatch.setattr(_auth, "is_auth_enabled", lambda: False)
|
||||
monkeypatch.setattr(routes, "is_auth_enabled", lambda: False, raising=False)
|
||||
monkeypatch.delenv("HERMES_WEBUI_TRUST_FORWARDED_FOR", raising=False)
|
||||
_reset_limiter()
|
||||
yield
|
||||
_reset_limiter()
|
||||
|
||||
|
||||
FRENCH_VOICES = [
|
||||
"fr-CA-AntoineNeural",
|
||||
"fr-CA-JeanNeural",
|
||||
"fr-CA-SylvieNeural",
|
||||
"fr-CA-ThierryNeural",
|
||||
"fr-FR-DeniseNeural",
|
||||
"fr-FR-EloiseNeural",
|
||||
"fr-FR-HenriNeural",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("voice", FRENCH_VOICES)
|
||||
def test_french_voice_in_allowlist_reaches_synthesis(monkeypatch, voice):
|
||||
captured = {}
|
||||
|
||||
class FakeCommunicate:
|
||||
def __init__(self, text, voice, **kwargs):
|
||||
captured["text"] = text
|
||||
captured["voice"] = voice
|
||||
|
||||
def stream_sync(self):
|
||||
yield {"type": "audio", "data": b"abc"}
|
||||
|
||||
monkeypatch.setitem(sys.modules, "edge_tts", SimpleNamespace(Communicate=FakeCommunicate))
|
||||
|
||||
# Unique client per voice so the 2s per-client rate limiter does not throttle
|
||||
# successive parametrized runs in this module.
|
||||
client = f"10.99.0.{FRENCH_VOICES.index(voice) + 1}"
|
||||
h = _post({"text": "Bonjour", "voice": voice}, client=client)
|
||||
routes._handle_tts(h, None)
|
||||
|
||||
assert h.status == 200, h.payload()
|
||||
assert captured["voice"] == voice
|
||||
|
||||
|
||||
def test_unlisted_french_locale_still_rejected():
|
||||
# fr-BE is a real Edge voice locale but is intentionally not in the
|
||||
# allowlist; it must still be rejected at the voice check before any
|
||||
# synthesis is attempted.
|
||||
h = _post({"text": "Bonjour", "voice": "fr-BE-CharlineNeural"}, client="10.99.1.1")
|
||||
routes._handle_tts(h, None)
|
||||
assert h.status == 400
|
||||
assert "invalid voice" in (h.payload() or {}).get("error", "")
|
||||
Reference in New Issue
Block a user