Discover lists speech models with the voices each accepts and a + Add that puts the model on tts.roster. The Roster card lists every model with a voice picker, Test, Make default and Remove. Test on any row (or a discovered model not yet added) fills the test panel's voice list with that model's voices, so Orpheus and Kokoro can be heard one voice at a time before either is chosen. The default is a pair — PUT /config/tts/default sets tts.model and tts.voice together and refuses a voice the model does not accept, naming the ones it does. The generic setter no longer takes tts.model/tts.voice one at a time, which is how a Kokoro voice got paired with Orpheus. A default that leaves the roster stops being the default. Users pick from the voices of every roster model, grouped by model in Settings; the stored value is "model|voice" so read-aloud sends the voice to the model that accepts it. A bare voice saved before there was a roster is read as a voice of the default model. chooseTTS is the one place the pair is decided, shared by read-aloud, the admin test and the settings options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
181 lines
11 KiB
JavaScript
181 lines
11 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const ENV_KEYS = ['TTS_PROVIDER', 'LITELLM_API_BASE', 'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'LITELLM_TTS_VOICES', 'LITELLM_TTS_VOICE'];
|
|
|
|
function withEnv(overrides, fn) {
|
|
const previous = {};
|
|
ENV_KEYS.forEach((key) => { previous[key] = process.env[key]; delete process.env[key]; });
|
|
Object.entries(overrides).forEach(([key, value]) => { process.env[key] = value; });
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
ENV_KEYS.forEach((key) => {
|
|
if (previous[key] === undefined) delete process.env[key];
|
|
else process.env[key] = previous[key];
|
|
});
|
|
}
|
|
}
|
|
|
|
test('TTS provider defaults to LiteLLM when gateway is configured', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
withEnv({ LITELLM_API_BASE: 'https://llm.example.com' }, () => {
|
|
assert.equal(ttsProvider.getTTSEnvProvider(), 'auto');
|
|
assert.equal(ttsProvider.getTTSProvider(), 'litellm');
|
|
});
|
|
});
|
|
|
|
test('TTS provider ignores non-LiteLLM provider overrides', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
withEnv({ TTS_PROVIDER: 'some-other-provider', LITELLM_API_BASE: 'https://llm.example.com' }, () => {
|
|
assert.equal(ttsProvider.getTTSEnvProvider(), 'some-other-provider');
|
|
assert.equal(ttsProvider.getTTSProvider(), 'litellm');
|
|
});
|
|
});
|
|
|
|
test('LiteLLM TTS voice list comes from configured voice catalog', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
withEnv({ LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam, sherpa/kokoro:af_bella' }, () => {
|
|
const voices = ttsProvider.getTTSVoiceLists().litellm;
|
|
assert.equal(voices[0], 'sherpa/kokoro:am_adam');
|
|
assert.equal(voices[1], 'sherpa/kokoro:af_bella');
|
|
// 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');
|
|
});
|
|
});
|
|
|
|
test('TTS model discovery uses audio_speech metadata only', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
assert.equal(ttsProvider.isOpenAITTSModel, undefined);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-chatterbox-turbo' }), false);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-kokoro-tts' }), false);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'openai-tts-1' }), false);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'openai-gpt-4o-mini-tts' }), false);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'mistral-voxtral-mini-tts' }), false);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'vertex-gemini-2.5-flash-tts' }), false);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } }), true);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } }), true);
|
|
assert.equal(ttsProvider.isLiteLLMTTSModel({ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } }), false);
|
|
});
|
|
|
|
test('LiteLLM TTS model extraction filters gateway model objects', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
assert.deepEqual(ttsProvider.getLiteLLMTTSModels([
|
|
{ id: 'openai-tts-1' },
|
|
{ id: 'mistral-voxtral-mini-tts' },
|
|
{ id: 'openai-gpt-4o-mini-tts' },
|
|
{ id: 'local-chatterbox-turbo' },
|
|
{ model_name: 'local-chatterbox-turbo', model_info: { mode: 'audio_speech' } },
|
|
{ id: 'custom-provider-model', model_info: { mode: 'audio_speech' } },
|
|
{ id: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
|
|
{ id: 'vertex-gemini-2.5-flash-tts' },
|
|
{ id: 'gpt-4.1' }
|
|
]), ['local-chatterbox-turbo', 'custom-provider-model']);
|
|
});
|
|
|
|
test('LiteLLM TTS discovery lists models, each carrying its own voices', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella' }, () => {
|
|
const items = ttsProvider.getLiteLLMTTSDiscoveryItems([
|
|
{ model_name: 'local-kokoro-tts', model_info: { mode: 'audio_speech' } },
|
|
{ model_name: 'not-tts-by-name-only' },
|
|
{ model_name: 'local-parakeet-v3', model_info: { mode: 'audio_transcription' } },
|
|
{ model_name: 'groq-orpheus-english', model_info: { mode: 'audio_speech' } }
|
|
], { currentModel: 'local-kokoro-tts', roster: ['local-kokoro-tts'] });
|
|
// No flat voice rows: a voice appears only under the model that accepts
|
|
// it. The screen adds a model and its voices come with it.
|
|
assert.deepEqual(items.map(i => i.kind), ['model', 'model']);
|
|
assert.deepEqual(items[0], { id: 'local-kokoro-tts', name: 'local-kokoro-tts', source: 'gateway-api', kind: 'model', voices: ['sherpa/kokoro:am_adam', 'sherpa/kokoro:af_bella'] });
|
|
assert.deepEqual(items[1].voices, ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy']);
|
|
assert.equal(items.some(function(item) { return item.id === 'not-tts-by-name-only'; }), false);
|
|
});
|
|
});
|
|
|
|
test('LiteLLM TTS discovery still lists the default and the roster if the metadata lookup fails', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
withEnv({}, () => {
|
|
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
|
|
currentModel: 'local-kokoro-tts',
|
|
roster: ['local-kokoro-tts', 'openrouter-fish-s2.1-pro-tts']
|
|
}).map(i => [i.id, i.source, i.voices.length]), [
|
|
['local-kokoro-tts', 'configured-model', 6],
|
|
['openrouter-fish-s2.1-pro-tts', 'configured-model', 1]
|
|
]);
|
|
});
|
|
});
|
|
|
|
test('chooseTTS sends a voice only to a roster model that accepts it, else the default pair', () => {
|
|
const { chooseTTS, voiceRef, parseVoiceRef, rosterVoices } = require('../src/utils/ttsProvider');
|
|
withEnv({}, () => {
|
|
const base = { roster: ['local-kokoro-tts', 'groq-orpheus-english'], defaultModel: 'local-kokoro-tts', defaultVoice: 'sherpa/kokoro:af_bella' };
|
|
// A user's pick names its model, so Orpheus gets an Orpheus voice.
|
|
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'groq-orpheus-english|hannah' })), { model: 'groq-orpheus-english', voice: 'hannah' });
|
|
// A voice the named model refuses, or a model that left the roster, falls back to the default pair.
|
|
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'groq-orpheus-english|sherpa/kokoro:af_bella' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
|
|
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'openrouter-fish-s2.1-pro-tts|alloy' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
|
|
// A bare voice saved before there was a roster is a voice of the default model.
|
|
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'sherpa/kokoro:am_adam' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:am_adam' });
|
|
assert.deepEqual(chooseTTS(Object.assign({}, base, { preferred: 'hannah' })), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
|
|
// A default voice the default model refuses is not sent either.
|
|
assert.deepEqual(chooseTTS({ roster: [], defaultModel: 'groq-orpheus-english', defaultVoice: 'sherpa/kokoro:af_bella' }), { model: 'groq-orpheus-english', voice: 'autumn' });
|
|
assert.deepEqual(chooseTTS({ roster: [], defaultModel: '' }), { model: '', voice: '' });
|
|
// The reference survives Kokoro's colon and every upstream slash.
|
|
assert.deepEqual(parseVoiceRef(voiceRef('local-kokoro-tts', 'sherpa/kokoro:af_bella')), { model: 'local-kokoro-tts', voice: 'sherpa/kokoro:af_bella' });
|
|
assert.deepEqual(rosterVoices(['openrouter-fish-s2.1-pro-tts', 'openrouter-fish-s2.1-pro-tts']), [{ model: 'openrouter-fish-s2.1-pro-tts', voice: 'alloy', value: 'openrouter-fish-s2.1-pro-tts|alloy' }]);
|
|
});
|
|
});
|
|
|
|
test('LiteLLM TTS voices are scoped to the active local model', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
// LITELLM_TTS_VOICES names the voices of LITELLM_TTS_MODEL and no other
|
|
// model — production sets both. Treating that list as universal is what
|
|
// offered Kokoro's voices for Fish.
|
|
withEnv({ LITELLM_TTS_MODEL: 'local-kokoro-tts', LITELLM_TTS_VOICES: 'sherpa/kokoro:am_adam,sherpa/kokoro:af_bella', LITELLM_TTS_VOICE: 'sherpa/kokoro:am_adam' }, () => {
|
|
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']);
|
|
});
|
|
});
|
|
|
|
test('LiteLLM TTS compatibility rejects cross-model local voices', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
// 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);
|
|
assert.equal(ttsProvider.isLiteLLMTTSVoiceCompatible('groq-orpheus-arabic-saudi', 'troy'), false);
|
|
});
|
|
|
|
test('Groq Orpheus TTS requests force wav response format', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('groq-orpheus-english'), { response_format: 'wav' });
|
|
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('canopylabs/orpheus-arabic-saudi'), { response_format: 'wav' });
|
|
assert.deepEqual(ttsProvider.getLiteLLMTTSRequestOptions('local-kokoro-tts'), {});
|
|
});
|
|
|
|
test('LiteLLM headers include optional content type without exposing key value', () => {
|
|
const ttsProvider = require('../src/utils/ttsProvider');
|
|
withEnv({ LITELLM_API_KEY: 'secret-token' }, () => {
|
|
assert.deepEqual(ttsProvider.getLiteLLMHeaders('application/json'), {
|
|
'Content-Type': 'application/json',
|
|
Authorization: 'Bearer secret-token'
|
|
});
|
|
});
|
|
});
|
|
|
|
test('LiteLLM admin headers prefer master key for metadata routes', () => {
|
|
const { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../src/utils/litellm');
|
|
withEnv({ LITELLM_API_KEY: 'virtual-token', LITELLM_MASTER_KEY: 'master-token' }, () => {
|
|
assert.equal(getLiteLLMHeaders().Authorization, 'Bearer virtual-token');
|
|
assert.equal(getLiteLLMAdminHeaders().Authorization, 'Bearer master-token');
|
|
});
|
|
});
|