From ff2b2bc9d3b1fae10268c7e04dce0f75d6bf0f34 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 16:45:05 +0200 Subject: [PATCH] chore: Kitten and Supertonic retired; Kokoro is the local voice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were removed from the gateway in favour of Kokoro, so the app's curated voice lists for them went too. Kokoro's own check no longer has to exclude them — it names its own voices and the list is open, so it accepts anything that is not another family's voice. Voices stay curated per family rather than discovered. Models are discovered from /model/info; voices are not, because no TTS provider exposes its voice list consistently, and a list guessed from a model id is how a picker ends up offering a voice the provider rejects. Tests that pinned the retired voices now pin the families that remain. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- src/utils/ttsProvider.js | 33 +++++++++++---------------------- test/policy-flows.test.js | 12 +++++++----- test/tts-provider.test.js | 37 +++++++++++++++++++++---------------- 3 files changed, 39 insertions(+), 43 deletions(-) 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);