"""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]