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:
Benoît Vézina
2026-06-12 09:44:24 -04:00
parent df6cfd0a15
commit 8003faa801
2 changed files with 112 additions and 1 deletions
+4 -1
View File
@@ -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
+108
View File
@@ -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", "")