chore: Kitten and Supertonic retired; Kokoro is the local voice
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
fce05a2749
commit
ff2b2bc9d3
3 changed files with 39 additions and 43 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue