From 954b13e7b34aecc1726ae331337330fd7135a53b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 05:04:37 +0200 Subject: [PATCH] fix: a speech model is added with its voices, and Orpheus is sent where it works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Orpheus id (groq-orpheus-english) did not start with "local-", so generate_tts_audio sent it down the OpenAI path and every call failed. It also could be added with no voice, and the "local-%" filters in /tts/voices and the default lookup hid any non-local voice from learners even when it was added and marked default. services/tts_voices.py is the one table of which voices belong to which model (Kokoro, Orpheus English/Arabic, Fish), the same table the scribe app keeps. Anything the table knows, or anything local-*, goes through the LiteLLM gateway with the options its family needs (Orpheus: wav). Adding a bare model id creates one row per voice with friendly names, so an administrator adds "groq-orpheus-english" and six voices appear to test one by one; a voice from another family is refused, naming the ones that work. Learners are offered every active voice, each saying which model serves it, and /tts/speak answers with the media type the model actually returned. Kitten and Supertonic tables go — those models left the gateway. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- backend/app/routers/admin.py | 91 ++++++++-------- backend/app/routers/tts.py | 18 ++-- backend/app/services/ai_service.py | 19 ++-- backend/app/services/tts_voices.py | 146 +++++++++++++++++++++++++ backend/tests/test_tts_voices.py | 165 +++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 56 deletions(-) create mode 100644 backend/app/services/tts_voices.py create mode 100644 backend/tests/test_tts_voices.py diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index f76625f..7992e38 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -14,7 +14,7 @@ from app.database import get_db from app.models.user import User from app.models.ai_model_config import AIModelConfig from app.models.invite import InviteCode -from app.services import ai_service, invites, site_settings +from app.services import ai_service, invites, site_settings, tts_voices from app.schemas.auth import UserResponse, UserUpdateRole, UserCreate from app.schemas.admin import AIModelConfigCreate, AIModelConfigResponse, AIModelConfigUpdate from app.utils.auth import require_admin, get_current_user, get_password_hash @@ -244,6 +244,20 @@ def create_model( if data.task not in valid_tasks: raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}") + if data.task == "tts": + if not tts_voices.accepts(data.model_id): + model_part, voice_part = tts_voices.split(data.model_id) + raise HTTPException( + status_code=400, + detail=f"{model_part} does not accept the voice {voice_part}. It accepts: " + + ", ".join(v for v, _ in tts_voices.voices_for(model_part))) + # A speech model is added with its voices: "groq-orpheus-english" + # becomes one row per voice the model accepts, so the administrator + # adds a model and the voices appear, ready to be tested one by one. + rows = tts_voices.expand(data.model_id, data.name if data.name != data.model_id else None) + if len(rows) > 1: + return _add_voice_rows(db, data, rows) + if data.is_default: db.query(AIModelConfig).filter( AIModelConfig.task == data.task, @@ -273,6 +287,32 @@ def create_model( return model +def _add_voice_rows(db: Session, data: AIModelConfigCreate, rows: list[tuple[str, str]]) -> AIModelConfig: + """One row per voice; the first becomes default only if the task had none. + + Returns the first row, which is what the caller shows. Voices already + present are left as they are rather than refused, so adding a model twice + fills in whatever was missing. + """ + existing = {m.model_id for m in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all()} + has_default = db.query(AIModelConfig).filter( + AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.is_default == True, + ).count() > 0 + first = None + for model_id, name in rows: + if model_id in existing: + continue + row = AIModelConfig(name=name, model_id=model_id, task="tts", api_key=data.api_key, + is_active=data.is_active, is_default=not has_default and first is None) + db.add(row) + first = first or row + if first is None: + raise HTTPException(status_code=409, detail=f"Every voice of '{data.model_id}' is already added") + db.commit() + db.refresh(first) + return first + + @router.put("/models/{model_id}", response_model=AIModelConfigResponse) def update_model( model_id: int, @@ -423,44 +463,10 @@ class TTSVoiceSearchRequest(BaseModel): region: str | None = None -KOKORO_VOICE_FALLBACKS = [ - ("am_adam", "Kokoro Adam"), - ("am_michael", "Kokoro Michael"), - ("af_bella", "Kokoro Bella"), - ("af_nicole", "Kokoro Nicole"), - ("bf_emma", "Kokoro Emma"), - ("bm_lewis", "Kokoro Lewis"), -] - -KITTEN_VOICE_FALLBACKS = [ - ("Bella", "Kitten Bella"), - ("Jasper", "Kitten Jasper"), - ("Luna", "Kitten Luna"), - ("Bruno", "Kitten Bruno"), - ("Rosie", "Kitten Rosie"), - ("Hugo", "Kitten Hugo"), - ("Kiki", "Kitten Kiki"), - ("Leo", "Kitten Leo"), -] - -SUPERTONIC_STYLE_FALLBACKS = [ - ("F1", "Supertonic F1"), - ("F2", "Supertonic F2"), - ("F3", "Supertonic F3"), - ("F4", "Supertonic F4"), - ("F5", "Supertonic F5"), - ("M1", "Supertonic M1"), - ("M2", "Supertonic M2"), - ("M3", "Supertonic M3"), - ("M4", "Supertonic M4"), - ("M5", "Supertonic M5"), -] - - def _kokoro_voice_options(model_name: str) -> list[dict]: base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/") voices = [] - friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS} + friendly_names = {voice_id: name for voice_id, name in tts_voices.KOKORO_VOICES} if base: try: resp = httpx.get(f"{base}/v1/audio/voices", timeout=10) @@ -475,7 +481,7 @@ def _kokoro_voice_options(model_name: str) -> list[dict]: if not voices: voices = [ {"voice": voice_id, "name": name, "profile": "kokoro"} - for voice_id, name in KOKORO_VOICE_FALLBACKS + for voice_id, name in tts_voices.KOKORO_VOICES ] return [ @@ -532,11 +538,12 @@ def search_tts_voices( if model_name == "local-kokoro-tts": voices.extend(_kokoro_voice_options(model_name)) continue - if model_name == "local-kitten-tts": - voices.extend(_static_voice_options(model_name, KITTEN_VOICE_FALLBACKS)) - continue - if model_name == "local-supertonic-tts": - voices.extend(_static_voice_options(model_name, SUPERTONIC_STYLE_FALLBACKS)) + # Kitten and Supertonic were retired from the gateway; Orpheus + # and Fish are what it proxies now, and their voices are the + # family table's. + known = tts_voices.voices_for(model_name) + if known: + voices.extend(_static_voice_options(model_name, known)) continue voices.append({ "model_id": model_name, diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py index 8f53411..6b91d18 100644 --- a/backend/app/routers/tts.py +++ b/backend/app/routers/tts.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.database import get_db from app.models.user import User from app.models.ai_model_config import AIModelConfig -from app.services import ai_service +from app.services import ai_service, tts_voices from app.utils.auth import get_current_user, check_rate_limit router = APIRouter() @@ -32,11 +32,13 @@ def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None] def _default_tts_model(db: Session) -> tuple[str, str | None]: + # Whatever the administrator marked default. The "local-%" filter that + # used to sit here meant an Orpheus default was ignored in favour of the + # hard-coded Kokoro voice below, silently. config = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", AIModelConfig.is_active == True, AIModelConfig.is_default == True, - AIModelConfig.model_id.like("local-%"), ).first() if config: return config.model_id, config.api_key or None @@ -48,14 +50,18 @@ def get_voices( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Return LiteLLM-routed TTS models only.""" + """Every active voice, each saying which model serves it. + + All of them, not only the locally served ones: a learner is offered every + voice an administrator added, whichever model it belongs to. + """ query = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", AIModelConfig.is_active == True, - AIModelConfig.model_id.like("local-%"), ) db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() - return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models] + return [{"id": m.model_id, "name": m.name, "is_default": m.is_default, + "model": tts_voices.split(m.model_id)[0]} for m in db_models] @router.post("/speak") @@ -101,7 +107,7 @@ def text_to_speech( if audio is None: raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.") - return Response(content=audio, media_type="audio/mpeg") + return Response(content=audio, media_type=tts_voices.media_type(model_id)) @router.post("/transcribe") diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 53bbb62..f79502f 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -5,6 +5,7 @@ import os import httpx from app.config import settings +from app.services import tts_voices logger = logging.getLogger(__name__) @@ -504,12 +505,14 @@ def generate_tts_audio( use_model = model_id or "tts-1:alloy" - # ── Local LiteLLM TTS models ───────────────────────────────── - if use_model.startswith("local-"): - local_model = use_model - local_voice = "am_adam" if use_model.startswith("local-kokoro-tts") else "alloy" - if ":" in use_model: - local_model, local_voice = use_model.split(":", 1) + # ── LiteLLM TTS models ─────────────────────────────────────── + # local-* and every family tts_voices knows (Orpheus, Fish). Orpheus used + # to miss this branch for not starting with "local-" and was sent down the + # OpenAI path, where it failed on every call. + if tts_voices.is_litellm_tts(use_model): + local_model, local_voice = tts_voices.split(use_model) + if not local_voice: + local_voice = tts_voices.default_voice(local_model) key = api_key or settings.LITELLM_API_KEY base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1") if not base: @@ -519,13 +522,13 @@ def generate_tts_audio( resp = httpx.post( f"{base}/v1/audio/speech", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"}, - json={"model": local_model, "input": text, "voice": local_voice, "response_format": "mp3"}, + json={"model": local_model, "input": text, "voice": local_voice, **tts_voices.request_options(local_model)}, timeout=90, ) resp.raise_for_status() return resp.content except Exception as e: - logger.error(f"Local LiteLLM TTS failed: {e}") + logger.error(f"LiteLLM TTS failed for {local_model}:{local_voice}: {e}") return None # ── Local Sherpa gateway ─────────────────────────────────── diff --git a/backend/app/services/tts_voices.py b/backend/app/services/tts_voices.py new file mode 100644 index 0000000..c45974c --- /dev/null +++ b/backend/app/services/tts_voices.py @@ -0,0 +1,146 @@ +"""Which voices belong to which LiteLLM speech model. + +A voice is a property of a model, not of the gateway, and the two used to be +treated as interchangeable: a model id like ``groq-orpheus-english`` could be +added with no voice at all, and ``generate_tts_audio`` then sent it down the +OpenAI path because it did not start with ``local-``. Every one of those calls +failed, which is what "voice is not set up properly" was. + +The gateway cannot help: ``/model/info`` reports ``audio_speech`` for all of +these and carries no voice field. So the mapping lives here, keyed by family, +and every list was taken from the provider rather than from documentation — +Groq names its own when refused ("voice must be one of the following voices: +[...]"), Fish accepts ``alloy`` and refuses the other OpenAI names, and +Kokoro's come from the speech gateway's ``/v1/audio/voices`` (admin.py asks it +directly and prefers that answer whenever it arrives). + +The same table lives in the scribe app (src/utils/ttsProvider.js). Keep the +two in step when a model is added to the gateway. + +Model ids keep the app's ``model:voice`` convention (``local-kokoro-tts:am_adam``). +""" + +KOKORO_VOICES = [ + ("am_adam", "Kokoro Adam"), ("am_michael", "Kokoro Michael"), + ("af_bella", "Kokoro Bella"), ("af_nicole", "Kokoro Nicole"), + ("bf_emma", "Kokoro Emma"), ("bm_lewis", "Kokoro Lewis"), +] +ORPHEUS_ENGLISH_VOICES = [ + ("autumn", "Orpheus Autumn"), ("diana", "Orpheus Diana"), ("hannah", "Orpheus Hannah"), + ("austin", "Orpheus Austin"), ("daniel", "Orpheus Daniel"), ("troy", "Orpheus Troy"), +] +ORPHEUS_ARABIC_VOICES = [ + ("abdullah", "Orpheus Abdullah"), ("fahad", "Orpheus Fahad"), ("sultan", "Orpheus Sultan"), + ("lulwa", "Orpheus Lulwa"), ("noura", "Orpheus Noura"), ("aisha", "Orpheus Aisha"), +] +FISH_VOICES = [("alloy", "Fish Alloy")] + +#: family → (gateway ids that belong to it, its voices, options the request needs) +FAMILIES = { + "kokoro": { + "ids": ("local-kokoro-tts",), + "voices": KOKORO_VOICES, + "request": {"response_format": "mp3"}, + "media_type": "audio/mpeg", + }, + "orpheus-english": { + "ids": ("groq-orpheus-english", "canopylabs/orpheus-v1-english"), + "voices": ORPHEUS_ENGLISH_VOICES, + # Groq refuses mp3 for Orpheus; wav is what it returns. + "request": {"response_format": "wav"}, + "media_type": "audio/wav", + }, + "orpheus-arabic": { + "ids": ("groq-orpheus-arabic-saudi", "canopylabs/orpheus-arabic-saudi"), + "voices": ORPHEUS_ARABIC_VOICES, + "request": {"response_format": "wav"}, + "media_type": "audio/wav", + }, + "fish": { + "ids": ("openrouter-fish-s2.1-pro-tts", "fish-audio/s2.1-pro"), + "voices": FISH_VOICES, + "request": {"response_format": "mp3"}, + "media_type": "audio/mpeg", + }, +} + + +def split(model_id: str | None) -> tuple[str, str]: + """``local-kokoro-tts:am_adam`` → (``local-kokoro-tts``, ``am_adam``); no colon → (id, '').""" + text = (model_id or "").strip() + if ":" in text: + model, voice = text.split(":", 1) + return model.strip(), voice.strip() + return text, "" + + +def family(model: str | None) -> str | None: + model, _ = split(model) + key = model.lower() + for name, spec in FAMILIES.items(): + if key in spec["ids"]: + return name + return None + + +def is_litellm_tts(model_id: str | None) -> bool: + """Routed through the LiteLLM gateway's /v1/audio/speech. + + ``local-*`` is the gateway's own convention for models it serves itself; + a known family is one it proxies. Both take the same request. + """ + model, _ = split(model_id) + return model.startswith("local-") or family(model) is not None + + +def voices_for(model: str | None) -> list[tuple[str, str]]: + """(voice, friendly name) pairs the model accepts, or [] when unknown.""" + spec = FAMILIES.get(family(model) or "") + return list(spec["voices"]) if spec else [] + + +def default_voice(model: str | None) -> str: + voices = voices_for(model) + if voices: + return voices[0][0] + # A local model we have no list for still needs something the gateway + # will take; alloy is what an OpenAI-shaped server defaults to. + return "alloy" + + +def request_options(model_id: str | None) -> dict: + spec = FAMILIES.get(family(model_id) or "") + return dict(spec["request"]) if spec else {"response_format": "mp3"} + + +def media_type(model_id: str | None) -> str: + spec = FAMILIES.get(family(model_id) or "") + return spec["media_type"] if spec else "audio/mpeg" + + +def accepts(model_id: str) -> bool: + """A ``model:voice`` pair the model will not refuse. + + A bare id whose family we know is fine (it gets its first voice); a voice + from another family is the one thing this says no to. + """ + model, voice = split(model_id) + known = voices_for(model) + if not voice or not known: + return True + return voice in {v for v, _ in known} + + +def expand(model_id: str, name: str | None = None) -> list[tuple[str, str]]: + """The rows adding ``model_id`` should create: one per voice. + + Adding ``groq-orpheus-english`` yields six ``groq-orpheus-english:`` + rows with friendly names, so the admin's "add a model" is what populates + the voices — nobody types six ids. An id that already names a voice, or a + model with no known list, is one row as given. + """ + model, voice = split(model_id) + known = voices_for(model) + if voice or not known: + return [(model_id, name or model_id)] + return [(f"{model}:{v}", label) for v, label in known] diff --git a/backend/tests/test_tts_voices.py b/backend/tests/test_tts_voices.py new file mode 100644 index 0000000..0f27aa3 --- /dev/null +++ b/backend/tests/test_tts_voices.py @@ -0,0 +1,165 @@ +"""A voice belongs to a model, and a model is added with its voices. + +Disposable SQLite. The rules worth pinning: an Orpheus id is routed through +the LiteLLM gateway (it used to fall through to the OpenAI path for not +starting with "local-"), a bare model id becomes one row per voice, a voice +from another family is refused with the list that would work, and learners +are offered every active voice — not only the locally served ones. +""" +import unittest +from unittest.mock import Mock, patch + +import test_quiz_builder as fixtures # noqa: F401 (sets DATABASE_URL before app imports) +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.database import Base, get_db +from app.models.ai_model_config import AIModelConfig +from app.models.user import User +from app.routers import admin, tts +from app.services import ai_service, tts_voices +from app.utils.auth import get_current_user, require_admin + + +class VoiceTableTests(unittest.TestCase): + def test_families_and_their_voices(self): + self.assertEqual(tts_voices.family("groq-orpheus-english:hannah"), "orpheus-english") + self.assertEqual(tts_voices.family("canopylabs/orpheus-arabic-saudi"), "orpheus-arabic") + self.assertEqual(tts_voices.family("openrouter-fish-s2.1-pro-tts"), "fish") + self.assertEqual(tts_voices.family("local-kokoro-tts:am_adam"), "kokoro") + self.assertIsNone(tts_voices.family("tts-1:alloy")) + self.assertEqual([v for v, _ in tts_voices.voices_for("groq-orpheus-english")], + ["autumn", "diana", "hannah", "austin", "daniel", "troy"]) + self.assertEqual(tts_voices.voices_for("openrouter-fish-s2.1-pro-tts"), [("alloy", "Fish Alloy")]) + self.assertEqual(tts_voices.voices_for("something-new"), []) + + def test_litellm_routing_and_request_shape(self): + self.assertTrue(tts_voices.is_litellm_tts("groq-orpheus-english:daniel")) + self.assertTrue(tts_voices.is_litellm_tts("local-anything:x")) + self.assertFalse(tts_voices.is_litellm_tts("tts-1:alloy")) + self.assertFalse(tts_voices.is_litellm_tts("elevenlabs/rachel")) + # Groq refuses mp3 for Orpheus; the gateway's own models take mp3. + self.assertEqual(tts_voices.request_options("groq-orpheus-english"), {"response_format": "wav"}) + self.assertEqual(tts_voices.media_type("groq-orpheus-english:troy"), "audio/wav") + self.assertEqual(tts_voices.request_options("local-kokoro-tts:af_bella"), {"response_format": "mp3"}) + self.assertEqual(tts_voices.media_type("local-kokoro-tts"), "audio/mpeg") + + def test_accepts_refuses_only_another_familys_voice(self): + self.assertTrue(tts_voices.accepts("groq-orpheus-english:hannah")) + self.assertFalse(tts_voices.accepts("groq-orpheus-english:am_adam")) + self.assertFalse(tts_voices.accepts("local-kokoro-tts:hannah")) + self.assertTrue(tts_voices.accepts("groq-orpheus-english")) # gets its first voice + self.assertTrue(tts_voices.accepts("local-newmodel-tts:whatever")) # no list: nothing to refuse + + def test_expand_makes_one_row_per_voice(self): + rows = tts_voices.expand("groq-orpheus-arabic-saudi") + self.assertEqual(len(rows), 6) + self.assertEqual(rows[0], ("groq-orpheus-arabic-saudi:abdullah", "Orpheus Abdullah")) + # An id that already names a voice, or a model we have no list for, is one row as given. + self.assertEqual(tts_voices.expand("local-kokoro-tts:am_adam", "Kokoro Adam"), [("local-kokoro-tts:am_adam", "Kokoro Adam")]) + self.assertEqual(tts_voices.expand("local-newmodel-tts"), [("local-newmodel-tts", "local-newmodel-tts")]) + + +class GenerateAudioRoutingTests(unittest.TestCase): + def _post(self, model_id): + calls = [] + + def fake_post(url, **kw): + calls.append((url, kw.get("json"))) + resp = Mock(); resp.content = b"audio"; resp.raise_for_status = Mock() + return resp + + with patch.object(ai_service.settings, "LITELLM_API_BASE", "http://gateway:4000/v1"), \ + patch.object(ai_service.settings, "LITELLM_API_KEY", "k"), \ + patch.object(ai_service.httpx, "post", side_effect=fake_post): + out = ai_service.generate_tts_audio("Stridor.", model_id=model_id) + return out, calls + + def test_orpheus_goes_to_the_gateway_as_wav(self): + out, calls = self._post("groq-orpheus-english:hannah") + self.assertEqual(out, b"audio") + url, body = calls[0] + self.assertEqual(url, "http://gateway:4000/v1/audio/speech") + self.assertEqual(body, {"model": "groq-orpheus-english", "input": "Stridor.", "voice": "hannah", "response_format": "wav"}) + + def test_bare_model_gets_its_first_voice(self): + _, calls = self._post("groq-orpheus-english") + self.assertEqual(calls[0][1]["voice"], "autumn") + _, calls = self._post("local-kokoro-tts") + self.assertEqual(calls[0][1]["voice"], "am_adam") + self.assertEqual(calls[0][1]["response_format"], "mp3") + + +class AdminAddsAModelWithItsVoicesTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(self.engine) + self.db = Session(self.engine) + self.admin = User(id=1, name="Admin", email="admin@example.test", hashed_password="x", role="admin") + self.db.add(self.admin); self.db.commit() + app = FastAPI() + app.include_router(admin.router, prefix="/admin") + app.include_router(tts.router, prefix="/tts") + app.dependency_overrides[get_db] = lambda: self.db + app.dependency_overrides[require_admin] = lambda: self.admin + app.dependency_overrides[get_current_user] = lambda: self.admin + self.client = TestClient(app) + + def tearDown(self): + self.client.close() + + def _add(self, model_id, **extra): + return self.client.post("/admin/models", json={"name": model_id, "model_id": model_id, "task": "tts", **extra}) + + def _rows(self): + return [(m.model_id, m.name, m.is_default) for m in + self.db.query(AIModelConfig).filter(AIModelConfig.task == "tts").order_by(AIModelConfig.id).all()] + + def test_a_bare_speech_model_becomes_its_voices(self): + res = self._add("groq-orpheus-english") + self.assertEqual(res.status_code, 200, res.text) + rows = self._rows() + self.assertEqual([r[0] for r in rows], [f"groq-orpheus-english:{v}" for v in ["autumn", "diana", "hannah", "austin", "daniel", "troy"]]) + self.assertEqual(rows[2][1], "Orpheus Hannah") + # The first voice is default only because the task had none. + self.assertEqual([r[2] for r in rows], [True, False, False, False, False, False]) + self.assertEqual(res.json()["model_id"], "groq-orpheus-english:autumn") + + def test_adding_a_second_model_keeps_the_existing_default(self): + self._add("local-kokoro-tts:am_adam", is_default=True) + self._add("groq-orpheus-english") + rows = self._rows() + self.assertEqual([r[0] for r in rows if r[2]], ["local-kokoro-tts:am_adam"]) + self.assertEqual(len(rows), 7) + # Again: nothing new to add. + self.assertEqual(self._add("groq-orpheus-english").status_code, 409) + + def test_a_voice_from_another_family_is_refused_with_the_right_list(self): + res = self._add("local-kokoro-tts:hannah") + self.assertEqual(res.status_code, 400) + self.assertIn("It accepts: am_adam, am_michael", res.json()["detail"]) + self.assertEqual(self._rows(), []) + + def test_learners_are_offered_every_active_voice(self): + self._add("local-kokoro-tts:am_adam") + self._add("groq-orpheus-english") + offered = self.client.get("/tts/voices").json() + self.assertEqual(len(offered), 7) + self.assertEqual({v["model"] for v in offered}, {"local-kokoro-tts", "groq-orpheus-english"}) + self.assertEqual(offered[0], {"id": "local-kokoro-tts:am_adam", "name": "local-kokoro-tts:am_adam", "is_default": True, "model": "local-kokoro-tts"}) + + def test_speak_answers_with_the_media_type_the_model_returns(self): + self._add("groq-orpheus-english") + with patch.object(tts.ai_service, "generate_tts_audio", return_value=b"RIFF") as gen, \ + patch.object(tts, "check_rate_limit", lambda **kw: None): + res = self.client.post("/tts/speak", json={"text": "Stridor.", "voice": "groq-orpheus-english:troy"}) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.headers["content-type"], "audio/wav") + self.assertEqual(gen.call_args.kwargs["model_id"], "groq-orpheus-english:troy") + + +if __name__ == "__main__": + unittest.main()