mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-10-03 17:15:19 -07:00
fix(speak): honour the voice profile's language instead of forcing English
Both speak surfaces built their GenerationRequest with a hardcoded "en" fallback and never consulted the resolved profile, so a profile created with language="fr" was still synthesised as English unless the caller passed language= explicitly. This hurts the MCP path most: an agent calling voicebox.speak has no way to know the bound profile's language, so it cannot pass the argument either. Every agent-triggered generation on a non-English profile came out with an English accent. The fallback chain is now explicit argument -> resolved profile's language -> "en", which matches how engine and personality already consult the resolved binding. The "en" backstop is kept so profiles with no language set behave exactly as before. Adds backend/tests/test_speak_language.py covering both surfaces: the fallback, explicit-argument precedence, and the unchanged "en" default. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
committed by
capy-ai-staging[bot]
co-authored by
Claude Opus 5
parent
e440b44ae7
commit
c339d2c324
@@ -104,7 +104,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|||||||
profile_name=vp.name,
|
profile_name=vp.name,
|
||||||
text=text,
|
text=text,
|
||||||
engine=resolved_engine,
|
engine=resolved_engine,
|
||||||
language=language,
|
language=language or vp.language,
|
||||||
personality=use_persona,
|
personality=use_persona,
|
||||||
model_size=model_size,
|
model_size=model_size,
|
||||||
db=db,
|
db=db,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async def speak(
|
|||||||
models.GenerationRequest(
|
models.GenerationRequest(
|
||||||
profile_id=profile.id,
|
profile_id=profile.id,
|
||||||
text=data.text,
|
text=data.text,
|
||||||
language=data.language or "en",
|
language=data.language or profile.language or "en",
|
||||||
engine=engine,
|
engine=engine,
|
||||||
personality=bool(personality_flag),
|
personality=bool(personality_flag),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Tests for voice-profile language fallback on the two speak surfaces.
|
||||||
|
|
||||||
|
Both speak paths built their ``GenerationRequest`` with a hardcoded ``"en"``
|
||||||
|
fallback and never consulted the resolved profile, so a profile created with
|
||||||
|
``language="fr"`` was still synthesised as English unless every caller passed
|
||||||
|
``language=`` explicitly. Agents going through MCP had no way to know the
|
||||||
|
profile's language, so they couldn't pass it either.
|
||||||
|
|
||||||
|
These tests pin the fix: the fallback chain is now explicit argument →
|
||||||
|
resolved profile's language → ``"en"``, matching how ``engine`` and
|
||||||
|
``personality`` already consult the resolved binding.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import backend.routes.generations as generations
|
||||||
|
import backend.routes.speak as speak_route
|
||||||
|
from backend import models
|
||||||
|
from backend.mcp_server import tools
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeGeneration:
|
||||||
|
"""Minimal stand-in for GenerationResponse consumed by the speak paths."""
|
||||||
|
|
||||||
|
id = "gen-test"
|
||||||
|
status = "generating"
|
||||||
|
|
||||||
|
def model_dump(self, mode="json"):
|
||||||
|
return {"id": self.id, "status": self.status}
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProfile:
|
||||||
|
def __init__(self, language):
|
||||||
|
self.id = "p1"
|
||||||
|
self.name = "Siwis"
|
||||||
|
self.language = language
|
||||||
|
self.personality = None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def filter(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
# No per-client binding — engine/personality fall through to their
|
||||||
|
# own defaults, leaving language as the only variable under test.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def query(self, *args, **kwargs):
|
||||||
|
return _FakeQuery()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
"""Stands in for starlette's Request — only headers are read."""
|
||||||
|
|
||||||
|
def __init__(self, client_id=None):
|
||||||
|
self.headers = {"X-Voicebox-Client-Id": client_id} if client_id else {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def captured_request(monkeypatch):
|
||||||
|
"""Capture the GenerationRequest instead of running a real generation.
|
||||||
|
|
||||||
|
Both speak paths import ``generate_speech`` lazily from
|
||||||
|
``routes.generations``, so patching the attribute on that module
|
||||||
|
intercepts the call on either surface.
|
||||||
|
"""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_generate_speech(req, db):
|
||||||
|
captured["req"] = req
|
||||||
|
return _FakeGeneration()
|
||||||
|
|
||||||
|
monkeypatch.setattr(generations, "generate_speech", fake_generate_speech)
|
||||||
|
monkeypatch.setattr(speak_route.mcp_events, "publish", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
# ─── REST: POST /speak ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_rest(monkeypatch, profile_language, requested_language=None):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
speak_route,
|
||||||
|
"resolve_profile",
|
||||||
|
lambda profile, client_id, db: _FakeProfile(profile_language),
|
||||||
|
)
|
||||||
|
await speak_route.speak(
|
||||||
|
models.SpeakRequest(text="Bonjour", language=requested_language),
|
||||||
|
_FakeRequest(client_id="claude-code"),
|
||||||
|
_FakeDB(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rest_speak_falls_back_to_profile_language(
|
||||||
|
captured_request, monkeypatch
|
||||||
|
):
|
||||||
|
await _call_rest(monkeypatch, profile_language="fr")
|
||||||
|
assert captured_request["req"].language == "fr"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rest_speak_explicit_language_wins(captured_request, monkeypatch):
|
||||||
|
# An explicit argument still overrides the profile — a French profile can
|
||||||
|
# be asked to read an English string.
|
||||||
|
await _call_rest(monkeypatch, profile_language="fr", requested_language="en")
|
||||||
|
assert captured_request["req"].language == "en"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rest_speak_defaults_to_en_without_profile_language(
|
||||||
|
captured_request, monkeypatch
|
||||||
|
):
|
||||||
|
# Profiles predating the language column resolve to None; the "en"
|
||||||
|
# backstop keeps their behaviour unchanged.
|
||||||
|
await _call_rest(monkeypatch, profile_language=None)
|
||||||
|
assert captured_request["req"].language == "en"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── MCP: voicebox.speak ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_mcp(monkeypatch, profile_language, requested_language=None):
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
tools,
|
||||||
|
"resolve_profile",
|
||||||
|
lambda profile, client_id, db: _FakeProfile(profile_language),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(tools, "get_db", lambda: iter([_FakeDB()]))
|
||||||
|
|
||||||
|
mcp = FastMCP("test")
|
||||||
|
tools.register_tools(mcp)
|
||||||
|
args = {"text": "Bonjour"}
|
||||||
|
if requested_language is not None:
|
||||||
|
args["language"] = requested_language
|
||||||
|
await mcp.call_tool("voicebox.speak", args)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_speak_falls_back_to_profile_language(
|
||||||
|
captured_request, monkeypatch
|
||||||
|
):
|
||||||
|
# The agent-facing path matters most: an MCP client can't know the
|
||||||
|
# profile's language, so omitting it must not silently mean English.
|
||||||
|
await _call_mcp(monkeypatch, profile_language="fr")
|
||||||
|
assert captured_request["req"].language == "fr"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_speak_explicit_language_wins(captured_request, monkeypatch):
|
||||||
|
await _call_mcp(monkeypatch, profile_language="fr", requested_language="en")
|
||||||
|
assert captured_request["req"].language == "en"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_speak_defaults_to_en_without_profile_language(
|
||||||
|
captured_request, monkeypatch
|
||||||
|
):
|
||||||
|
await _call_mcp(monkeypatch, profile_language=None)
|
||||||
|
assert captured_request["req"].language == "en"
|
||||||
Reference in New Issue
Block a user