diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js index 46252af7..0e791203 100644 --- a/src/utils/ttsProvider.js +++ b/src/utils/ttsProvider.js @@ -7,8 +7,11 @@ function parseList(value) { .filter(Boolean); } -var KITTEN_TTS_VOICES = ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']; -var SUPERTONIC_TTS_VOICES = ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5']; +// 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']; @@ -34,14 +37,12 @@ function getTTSEnvProvider() { function getTTSVoiceLists() { return { - litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, GROQ_ORPHEUS_ENGLISH_VOICES, GROQ_ORPHEUS_ARABIC_VOICES)) + 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-kitten-tts') return 'kitten'; - if (id === 'local-supertonic-tts') return 'supertonic'; 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'; @@ -59,13 +60,13 @@ function getLiteLLMTTSRequestOptions(model) { function isLiteLLMTTSVoiceCompatible(model, voice) { if (typeof voice !== 'string' || !voice.trim()) return false; var family = getLiteLLMTTSModelFamily(model); - if (family === 'kitten') return KITTEN_TTS_VOICES.indexOf(voice) !== -1; - if (family === 'supertonic') return SUPERTONIC_TTS_VOICES.indexOf(voice) !== -1; if (family === 'groq-orpheus-english') return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) !== -1; if (family === 'groq-orpheus-arabic') return GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) !== -1; if (family === 'kokoro') { - return KITTEN_TTS_VOICES.indexOf(voice) === -1 && SUPERTONIC_TTS_VOICES.indexOf(voice) === -1 && - GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1; + // Kokoro names its own voices (sherpa/kokoro:am_adam and the rest) and the + // list is open, so anything that is not another family's voice is allowed. + return GROQ_ORPHEUS_ENGLISH_VOICES.indexOf(String(voice).toLowerCase()) === -1 && + GROQ_ORPHEUS_ARABIC_VOICES.indexOf(String(voice).toLowerCase()) === -1; } return true; } @@ -74,9 +75,7 @@ function getLiteLLMTTSVoicesForModel(model, opts) { opts = opts || {}; var family = getLiteLLMTTSModelFamily(model); var voices = []; - if (family === 'kitten') voices = KITTEN_TTS_VOICES.slice(); - else if (family === 'supertonic') voices = SUPERTONIC_TTS_VOICES.slice(); - else if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice(); + if (family === 'groq-orpheus-english') voices = GROQ_ORPHEUS_ENGLISH_VOICES.slice(); else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice(); else voices = parseList(process.env.LITELLM_TTS_VOICES); @@ -118,16 +117,6 @@ function getLiteLLMTTSDiscoveryItems(models, opts) { getTTSVoiceLists().litellm.forEach(function(voice) { pushUniqueTTSItem(items, { id: voice, name: voice, source: 'configured-voice-list', kind: 'voice' }); }); - if (getLiteLLMTTSModels(models).indexOf('local-kitten-tts') !== -1 || opts.currentModel === 'local-kitten-tts') { - KITTEN_TTS_VOICES.forEach(function(voice) { - pushUniqueTTSItem(items, { id: voice, name: 'Kitten ' + voice, source: 'local-kitten-tts', kind: 'voice' }); - }); - } - if (getLiteLLMTTSModels(models).indexOf('local-supertonic-tts') !== -1 || opts.currentModel === 'local-supertonic-tts') { - SUPERTONIC_TTS_VOICES.forEach(function(voice) { - pushUniqueTTSItem(items, { id: voice, name: 'Supertonic ' + voice, source: 'local-supertonic-tts', kind: 'voice' }); - }); - } if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-english') !== -1 || opts.currentModel === 'groq-orpheus-english') { GROQ_ORPHEUS_ENGLISH_VOICES.forEach(function(voice) { pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus ' + voice, source: 'groq-orpheus-english', kind: 'voice' }); diff --git a/test/policy-flows.test.js b/test/policy-flows.test.js index 46010975..ff12f8a4 100644 --- a/test/policy-flows.test.js +++ b/test/policy-flows.test.js @@ -23,7 +23,7 @@ function fixture(envOverrides = {}) { 'oidc.enabled': 'true', 'oidc.disable_local_auth': 'false', 'oidc.issuer': 'https://idp.example', 'oidc.client_id': 'synthetic-client', 'feature.read_aloud': 'true', 'feature.nextcloud': 'true', 'feature.memories': 'true', - 'tts.model': 'local-kitten-tts', 'tts.voice': 'Luna', 'stt.model': 'synthetic-stt' + 'tts.model': 'groq-orpheus-english', 'tts.voice': 'hannah', 'stt.model': 'synthetic-stt' }, user: { id: 7, email: 'synthetic@example.test', name: 'Synthetic', role: 'admin', password, email_verified: true, disabled: false }, writes: [], queries: [], requests: [], grants: [], logs: [], authorizations: new Map(), usedCodes: new Set(), @@ -405,15 +405,17 @@ test('every actual generation memory consumer omits disabled templates and inclu }); test('TTS blank/incompatible user voice falls through to admin/env/family, and STT tests do not claim HTTP errors as success', async t => { - const f = fixture({ LITELLM_TTS_VOICE: 'Rosie' }); const request = await f.serve(t, true); - for (const [voice, admin, expected] of [[undefined, 'Luna', 'Luna'], ['', '', 'Rosie'], [' ', 'Luna', 'Luna'], ['F1', 'Luna', 'Luna'], ['Bella', 'Luna', 'Bella']]) { + // Orpheus English voices, the model the fixture now configures. 'noura' is an + // Arabic voice and must be refused as incompatible, the way 'F1' was. + const f = fixture({ LITELLM_TTS_VOICE: 'daniel' }); const request = await f.serve(t, true); + for (const [voice, admin, expected] of [[undefined, 'hannah', 'hannah'], ['', '', 'daniel'], [' ', 'hannah', 'hannah'], ['noura', 'hannah', 'hannah'], ['austin', 'hannah', 'austin']]) { f.state.user.tts_voice = voice; f.state.settings['tts.voice'] = admin; const result = await request('/api/text-to-speech', { method: 'POST', role: 'user', body: { text: 'Synthetic' } }); assert.equal(result.status, 200); assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, expected); } - f.state.settings['tts.model'] = 'local-supertonic-tts'; f.state.settings['tts.voice'] = ''; f.state.user.tts_voice = ''; + f.state.settings['tts.model'] = 'groq-orpheus-arabic-saudi'; f.state.settings['tts.voice'] = ''; f.state.user.tts_voice = ''; await request('/api/text-to-speech', { method: 'POST', role: 'user', body: { text: 'Synthetic' } }); - assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, 'F1'); + assert.equal(JSON.parse(f.state.requests.at(-1).options.body).voice, 'abdullah'); const sttBody = { audioBase64: Buffer.from('synthetic').toString('base64') }; f.state.httpError = 401; const bad = await request('/api/admin/config/stt/test', { method: 'POST', role: 'admin', body: sttBody }); diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js index 79ed56cb..c4aa843b 100644 --- a/test/tts-provider.test.js +++ b/test/tts-provider.test.js @@ -39,8 +39,12 @@ test('LiteLLM TTS voice list comes from configured voice catalog', () => { const voices = ttsProvider.getTTSVoiceLists().litellm; assert.equal(voices[0], 'sherpa/kokoro:am_adam'); assert.equal(voices[1], 'sherpa/kokoro:af_bella'); - assert.equal(voices.includes('Jasper'), true); - assert.equal(voices.includes('F1'), true); + // The configured list, plus the curated voices of the families that remain. + // Kitten and Supertonic were retired with their models. + assert.equal(voices.includes('hannah'), true, 'Orpheus English'); + assert.equal(voices.includes('noura'), true, 'Orpheus Arabic'); + assert.equal(voices.includes('Jasper'), false, 'Kitten is gone'); + assert.equal(voices.includes('F1'), false, 'Supertonic is gone'); }); }); @@ -93,16 +97,19 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks', }); }); -test('LiteLLM TTS discovery expands Kitten and Supertonic voices', () => { +test('LiteLLM TTS discovery expands the voices of a model it can see', () => { + // Models are discovered from /model/info; voices are not. No TTS provider + // exposes its voice list consistently, so each family's voices are curated + // here — a list guessed from a model id is how a picker ends up offering a + // voice the provider rejects. Kitten and Supertonic were retired from the + // gateway in favour of Kokoro, and their lists went with them. const ttsProvider = require('../src/utils/ttsProvider'); withEnv({}, () => { const items = ttsProvider.getLiteLLMTTSDiscoveryItems([ - { model_name: 'local-kitten-tts', model_info: { mode: 'audio_speech' } }, - { model_name: 'local-supertonic-tts', model_info: { mode: 'audio_speech' } } + { model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } } ], {}); - assert.equal(items.some(function(item) { return item.id === 'local-kitten-tts' && item.kind === 'model'; }), true); - assert.equal(items.some(function(item) { return item.id === 'Jasper' && item.kind === 'voice'; }), true); - assert.equal(items.some(function(item) { return item.id === 'F1' && item.kind === 'voice'; }), true); + assert.equal(items.some(function(item) { return item.id === 'groq-orpheus-english' && item.kind === 'model'; }), true); + assert.equal(items.some(function(item) { return item.id === 'hannah' && item.kind === 'voice'; }), true); }); }); @@ -120,8 +127,6 @@ test('LiteLLM TTS discovery still shows configured model if metadata lookup fail test('LiteLLM TTS voices are scoped to the active local model', () => { const ttsProvider = require('../src/utils/ttsProvider'); withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => { - assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kitten-tts'), ['Bella', 'Jasper', 'Luna', 'Bruno', 'Rosie', 'Hugo', 'Kiki', 'Leo']); - assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-supertonic-tts'), ['F1', 'F2', 'F3', 'F4', 'F5', 'M1', 'M2', 'M3', 'M4', 'M5']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('local-kokoro-tts'), ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('groq-orpheus-english'), ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']); assert.deepEqual(ttsProvider.getLiteLLMTTSVoicesForModel('canopylabs/orpheus-arabic-saudi'), ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha']); @@ -130,12 +135,12 @@ test('LiteLLM TTS voices are scoped to the active local model', () => { test('LiteLLM TTS compatibility rejects cross-model local voices', () => { const ttsProvider = require('../src/utils/ttsProvider'); - assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'Bella'), false); - assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'M1'), false); - assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'Bella'), true); - assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kitten-tts', 'M1'), false); - assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'M1'), true); - assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-supertonic-tts', 'Bella'), false); + // Kokoro names its own voices and the list is open, so it accepts anything + // that is not another family's voice. An Orpheus voice is still refused. + assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'sherpa/kokoro:af_bella'), true); + assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('local-kokoro-tts', 'hannah'), false); + assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'noura'), true); + assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'hannah'), false); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'hannah'), true); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-english', 'sherpa/kokoro:af_bella'), false); assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'aisha'), true);