const { getLiteLLMHeaders } = require('./litellm'); function parseList(value) { return String(value || '') .split(',') .map(function(item) { return item.trim(); }) .filter(Boolean); } // Kitten and Supertonic were retired from the gateway in favour of Kokoro, so // their voice lists went with them. Voices are curated per family rather than // discovered: no TTS provider exposes its voices consistently, and a list // guessed from a model id is how a picker ends up offering a voice the provider // will reject. var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']; var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']; // ── Which voices belong to which model ─────────────────────────────────── // A voice is not a property of the gateway, it is a property of the model, and // the two were being listed side by side as if interchangeable. Picking Orpheus // and testing it with a Kokoro voice is not a configuration, it is an error — // every one of those calls came back 500, which is what "these settings don't // work" was. // // The gateway cannot help: /model/info reports mode audio_speech for all four // of these and carries no voice field at all. So the mapping lives here. // // Every list below was taken from the provider rather than from documentation. // Groq states its own when refused: "voice must be one of the following // voices: [...]". Fish was found by trying the OpenAI names — alloy returns // audio and the rest are refused. Kokoro's come from the speech gateway's own // /v1/audio/voices, which is authoritative and is preferred over this list // whenever it answers. // Keyed by family, not by id: getLiteLLMTTSModelFamily already maps both the // gateway alias (groq-orpheus-english) and the upstream id // (canopylabs/orpheus-v1-english) to one family, and a table keyed by id would // have to repeat every list under every spelling. var MODEL_VOICES = Object.freeze({ 'groq-orpheus-english': Object.freeze(GROQ_ORPHEUS_ENGLISH_VOICES.slice()), 'groq-orpheus-arabic': Object.freeze(GROQ_ORPHEUS_ARABIC_VOICES.slice()), 'fish': Object.freeze(['alloy']), 'kokoro': Object.freeze([ 'sherpa/kokoro:am_adam', 'sherpa/kokoro:am_michael', 'sherpa/kokoro:af_bella', 'sherpa/kokoro:af_nicole', 'sherpa/kokoro:bf_emma', 'sherpa/kokoro:bm_lewis' ]) }); /** * The voices a model will actually accept, or [] when we do not know. * * [] is an honest answer and a useful one: the screen can say "no voice list * for this model, type one" instead of offering another model's voices, which * is the thing that was broken. */ function voicesForModel(model) { var id = String(model || ''); // The environment wins for the model it was written for. LITELLM_TTS_VOICES // names the voices of LITELLM_TTS_MODEL, so a deployment can change that // model's voices without changing this file — which matters for the local // gateway, whose list is whatever it has been built with. It answers for // that model only: it never described any other, and treating it as a // general list is what offered Kokoro's voices for Fish. if (id && id === String(process.env.LITELLM_TTS_MODEL || '')) { var configured = parseList(process.env.LITELLM_TTS_VOICES); if (configured.length) return configured; } var known = MODEL_VOICES[getLiteLLMTTSModelFamily(id)]; return known ? known.slice() : []; } function uniqueList(values) { var seen = new Set(); return (values || []).filter(function(value) { if (!value || seen.has(value)) return false; seen.add(value); return true; }); } function getTTSProvider() { var env = process.env.TTS_PROVIDER; if (env === 'litellm') return 'litellm'; if (process.env.LITELLM_API_BASE) return 'litellm'; return 'none'; } function getTTSEnvProvider() { return process.env.TTS_PROVIDER || 'auto'; } function getTTSVoiceLists() { return { litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES)) }; } function getLiteLLMTTSModelFamily(model) { var id = String(model || '').toLowerCase(); if (id === 'local-kokoro-tts') return 'kokoro'; if (id === 'groq-orpheus-english' || id === 'canopylabs/orpheus-v1-english') return 'groq-orpheus-english'; if (id === 'groq-orpheus-arabic-saudi' || id === 'canopylabs/orpheus-arabic-saudi') return 'groq-orpheus-arabic'; if (id === 'openrouter-fish-s2.1-pro-tts' || id === 'fish-audio/s2.1-pro') return 'fish'; return 'unknown'; } function getLiteLLMTTSRequestOptions(model) { var family = getLiteLLMTTSModelFamily(model); if (family === 'groq-orpheus-english' || family === 'groq-orpheus-arabic') { return { response_format: 'wav' }; } return {}; } function isLiteLLMTTSVoiceCompatible(model, voice) { if (typeof voice !== 'string' || !voice.trim()) return false; var known = voicesForModel(model); // A model we have a list for accepts what is on it and nothing else — that // refusal is the whole point, and it is what stops a Kokoro voice being // offered for Orpheus. if (known.length) return known.indexOf(String(voice)) !== -1 || known.indexOf(String(voice).toLowerCase()) !== -1; // A model we have no list for: anything is allowed, because refusing would // mean refusing every voice of a model added after this code was written. // Another model's voice is still refused — those we do know to be wrong. var family = getLiteLLMTTSModelFamily(model); return !Object.keys(MODEL_VOICES).some(function (other) { return other !== family && MODEL_VOICES[other].indexOf(String(voice).toLowerCase()) !== -1; }); } function getLiteLLMTTSVoicesForModel(model, opts) { opts = opts || {}; // One table, MODEL_VOICES, rather than a second copy of the same knowledge. // The old branch fell through to LITELLM_TTS_VOICES for any model it did not // recognise, so an unknown model was offered Kokoro's voices — which is how // Fish came to be listed with six voices it refuses. var voices = voicesForModel(model); [opts.currentVoice, process.env.LITELLM_TTS_VOICE].forEach(function(voice) { if (isLiteLLMTTSVoiceCompatible(model, voice)) voices.push(voice); }); return uniqueList(voices.filter(function(voice) { return isLiteLLMTTSVoiceCompatible(model, voice); })); } function isLiteLLMTTSModel(model) { var mode = model && model.model_info && model.model_info.mode ? String(model.model_info.mode) : ''; return mode === 'audio_speech'; } function getLiteLLMTTSModels(models) { return (models || []) .filter(isLiteLLMTTSModel) .map(function(model) { return model && (model.id || model.model_name) ? (model.id || model.model_name) : String(model || ''); }); } function pushUniqueTTSItem(items, item) { if (!item || !item.id) return; if (items.some(function(existing) { return existing.id === item.id && existing.kind === item.kind; })) return; items.push(item); } function getLiteLLMTTSDiscoveryItems(models, opts) { opts = opts || {}; var items = []; getLiteLLMTTSModels(models).forEach(function(id) { pushUniqueTTSItem(items, { id: id, name: id, source: 'gateway-api', kind: 'model' }); }); if (opts.currentModel) { pushUniqueTTSItem(items, { id: opts.currentModel, name: opts.currentModel, source: 'configured-model', kind: 'model' }); } if (opts.currentVoice) { pushUniqueTTSItem(items, { id: opts.currentVoice, name: opts.currentVoice, source: 'configured-voice', kind: 'voice' }); } // Every voice now says which model it belongs to. They used to be pushed // into one flat list, so the screen offered twelve Orpheus voices and six // Kokoro ones together with no way to tell which model would accept which. var offered = getLiteLLMTTSModels(models).slice(); if (opts.currentModel && offered.indexOf(opts.currentModel) === -1) offered.push(opts.currentModel); offered.forEach(function(model) { voicesForModel(model).forEach(function(voice) { pushUniqueTTSItem(items, { id: voice, name: voice, source: model, kind: 'voice', model: model }); }); }); return items; } module.exports = { voicesForModel, MODEL_VOICES, getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMHeaders, getLiteLLMTTSModels, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, getTTSProvider, getTTSVoiceLists, isLiteLLMTTSVoiceCompatible, isLiteLLMTTSModel };