Match TTS voices to the selected LiteLLM model

Voice lists were a single flat set from LITELLM_TTS_VOICES, so picking a
model could leave an incompatible voice selected and the request would
fail at the gateway. Voices are now resolved per model family (Kokoro,
Kitten, Supertonic, Groq Orpheus EN/AR), with a compatibility check that
falls back through user → admin → env → first valid voice. Groq Orpheus
requests also pin response_format to wav.

Also refreshes the cardiac/respiratory auscultation samples, extends the
well-visit component, and fixes the Android launch theme background
(@null → colorPrimary) so the splash does not flash through.

NOTE: this is in-progress work that was already sitting uncommitted in
the working tree; it is committed here as-is so the tree was clean for
the release bump.
This commit is contained in:
Daniel 2026-07-30 17:34:34 +02:00
parent 82ed46d01b
commit 524ad40d49
21 changed files with 246 additions and 21 deletions

View file

@ -13,7 +13,7 @@
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:background">@color/colorPrimary</item>
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
<item name="android:navigationBarColor">@color/colorPrimaryDark</item>
</style>
@ -22,4 +22,4 @@
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
</resources>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -11,6 +11,9 @@
<button class="wv-subtab-btn" data-subtab="milestones">
<i class="fas fa-baby"></i> Milestones
</button>
<button class="wv-subtab-btn" data-subtab="lincoln">
<i class="fas fa-clipboard-list"></i> Lincoln
</button>
<button class="wv-subtab-btn" data-subtab="shadess" style="display:none;">
<i class="fas fa-brain"></i> SSHADESS (12+)
</button>
@ -119,6 +122,65 @@
</div>
<!-- Lincoln quick-reference sub-panel -->
<div id="wv-panel-lincoln" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;">
<div class="card-header output-header">
<h3><i class="fas fa-clipboard-list"></i> Lincoln Well-Child Quick Reference</h3>
<div class="output-actions">
<button class="btn-sm btn-primary" data-action="copy" data-target="wv-lincoln-reference"><i class="fas fa-copy"></i> Copy</button>
</div>
</div>
<div id="wv-lincoln-reference" class="wv-lincoln-reference">
<div class="wv-lincoln-grid">
<section class="wv-lincoln-card">
<h4>Infancy</h4>
<ul>
<li><strong>Newborn:</strong> POC visit; check for jaundice.</li>
<li><strong>2 weeks:</strong> weight gain, newborn screen, umbilicus check.</li>
<li><strong>1 month:</strong> maternal PHQ-9.</li>
<li><strong>2 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>4 months:</strong> Vaxelis, rotavirus, Prevnar.</li>
<li><strong>6 months:</strong> routine vaccines and Prevnar; confirm rotavirus eligibility by product and age.</li>
<li><strong>9 months:</strong> SWYC; no routine vaccines noted.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Toddler / Preschool</h4>
<ul>
<li><strong>12 months:</strong> MMR, varicella, Hep A; CBC and lead.</li>
<li><strong>15 months:</strong> Pentacel, Prevnar, influenza.</li>
<li><strong>18 months:</strong> POSI, SWYC; Hep A second dose.</li>
<li><strong>2 years:</strong> POSI/SWYC; CBC and lead.</li>
<li><strong>3 years:</strong> blood pressure check and vision screening; BP is commonly missed and can be added on diagnosis.</li>
<li><strong>4 years:</strong> hearing and vision start; ProQuad and Kinrix.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>School Age / Adolescence</h4>
<ul>
<li><strong>Lipid screening:</strong> AAP screening at 9-11 years and 17-21 years.</li>
<li><strong>Depression screening:</strong> begin at 12 years and older.</li>
<li><strong>MenB:</strong> discuss Bexsero/MenB at 16-23 years, preferably 16-18 years, when chosen or indicated.</li>
<li><strong>Age &ge;18 years:</strong> Hep C testing.</li>
<li><strong>Cervical cancer screening:</strong> start Pap smear screening at 21 years.</li>
</ul>
</section>
<section class="wv-lincoln-card">
<h4>Catch-Up / Screening Reminders</h4>
<ul>
<li><strong>Influenza:</strong> if a child 6 months through 8 years needs 2 doses, give doses 4 weeks apart.</li>
<li><strong>Lead:</strong> continue lead screening reminders through age 6 years; add diagnosis when needed.</li>
</ul>
</section>
</div>
</div>
</div>
</div>
<!-- SSHADESS sub-panel (age 12+) -->
<div id="wv-panel-shadess" class="wv-subpanel hidden">
<div class="card" style="margin-bottom:10px;">
@ -305,4 +367,3 @@
</div>
</div>
</div>

View file

@ -397,6 +397,21 @@ textarea.full-input{resize:vertical;}
.wv-section-title{font-size:14px;font-weight:700;color:var(--g700);margin-bottom:12px;display:flex;align-items:center;gap:8px;}
.wv-section-title i{color:var(--blue);}
/* Lincoln quick reference */
.wv-lincoln-reference{padding:14px 16px;background:var(--g50);}
.wv-lincoln-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:14px;}
.wv-lincoln-card{border:1px solid var(--g100);border-radius:12px;padding:14px 16px;background:white;box-shadow:0 1px 2px rgba(15,23,42,0.04);}
.wv-lincoln-card h4{margin:0 0 10px;font-size:14px;color:var(--g800);}
.wv-lincoln-card ul{margin:0;padding-left:18px;color:var(--g700);font-size:13px;line-height:1.65;}
.wv-lincoln-card li{margin-bottom:6px;}
.wv-lincoln-card li:last-child{margin-bottom:0;}
@media(max-width:640px){
.wv-lincoln-reference{padding:10px;}
.wv-lincoln-grid{grid-template-columns:1fr;gap:10px;}
.wv-lincoln-card{padding:12px;}
.wv-lincoln-card ul{font-size:12.5px;line-height:1.55;}
}
/* Billing */
.wv-billing-grid{display:flex;flex-wrap:wrap;gap:12px;align-items:center;}
.wv-billing-cell{display:flex;align-items:center;gap:8px;}

View file

@ -9,7 +9,7 @@ var { authMiddleware, adminMiddleware } = require('../middleware/auth');
var PROMPTS = require('../utils/prompts');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getTTSEnvProvider, getLiteLLMTTSDiscoveryItems, getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders, getLiteLLMAdminHeaders } = require('../utils/litellm');
var { getSTTDependencies, getLiteLLMSTTModels, getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getLiteLLMEmbeddingModels } = require('../utils/embeddings');
@ -550,12 +550,17 @@ router.get('/config/tts', async function(req, res) {
var dbModel = await db.getSetting('tts.model') || '';
var envVoice = process.env.LITELLM_TTS_VOICE || '';
var envModel = process.env.LITELLM_TTS_MODEL || '';
var currentModel = dbModel || envModel;
var voices = getLiteLLMTTSVoicesForModel(currentModel, { currentVoice: dbVoice });
var currentVoice = [dbVoice, envVoice, voices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(currentModel, voice);
}) || '';
res.json({
success: true,
provider: activeProvider,
envProvider: envProvider,
currentVoice: dbVoice || envVoice,
currentModel: dbModel || envModel,
currentVoice: currentVoice,
currentModel: currentModel,
dbVoice: dbVoice,
dbModel: dbModel,
envVoice: envVoice,
@ -563,7 +568,9 @@ router.get('/config/tts', async function(req, res) {
configured: {
litellm: !!process.env.LITELLM_API_BASE
},
voices: getTTSVoiceLists()
voices: {
litellm: voices
}
});
} catch (e) { res.status(500).json({ error: 'Request failed' }); }
});
@ -616,11 +623,15 @@ router.post('/config/tts/test', async function(req, res) {
var adminModel = await db.getSetting('tts.model') || '';
var adminVoice = await db.getSetting('tts.voice') || '';
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var usedVoice = voice || adminVoice || process.env.LITELLM_TTS_VOICE || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var usedVoice = [voice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(candidate) {
return isLiteLLMTTSVoiceCompatible(ttsModel, candidate);
}) || '';
if (!ttsModel) return res.json({ success: false, error: 'No LiteLLM TTS model configured' });
var payload = Object.assign({ model: ttsModel, voice: usedVoice, input: text }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await axios.post(gatewayUrl('/audio/speech'),
{ model: ttsModel, voice: usedVoice, input: text },
payload,
{ headers: getLiteLLMHeaders('application/json'), responseType: 'arraybuffer', timeout: 60000 }
);
var buffer = Buffer.from(ttsResp.data);

View file

@ -3,7 +3,7 @@ const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var { gatewayUrl } = require('../utils/errors');
var { getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMTTSRequestOptions, getLiteLLMTTSVoicesForModel, isLiteLLMTTSVoiceCompatible, getTTSProvider } = require('../utils/ttsProvider');
var { getLiteLLMHeaders } = require('../utils/litellm');
// TTS is intentionally routed only through LiteLLM. Provider-specific voice
@ -22,19 +22,22 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
var userVoice = userPrefs?.tts_voice;
var adminVoice = await db.getSetting('tts.voice') || '';
var adminModel = await db.getSetting('tts.model') || '';
var resolvedVoice = userVoice || adminVoice || '';
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
}
var ttsModel = adminModel || process.env.LITELLM_TTS_MODEL || '';
var ttsVoice = resolvedVoice || process.env.LITELLM_TTS_VOICE || '';
var defaultVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: adminVoice });
var ttsVoice = [userVoice, adminVoice, process.env.LITELLM_TTS_VOICE || '', defaultVoices[0]].find(function(voice) {
return isLiteLLMTTSVoiceCompatible(ttsModel, voice);
}) || '';
if (!ttsModel) return res.status(400).json({ error: 'No LiteLLM TTS model configured.' });
var payload = Object.assign({ model: ttsModel, input: text, voice: ttsVoice }, getLiteLLMTTSRequestOptions(ttsModel));
var ttsResp = await fetch(gatewayUrl('/audio/speech'), {
method: 'POST',
headers: getLiteLLMHeaders('application/json'),
body: JSON.stringify({ model: ttsModel, input: text, voice: ttsVoice })
body: JSON.stringify(payload)
});
if (!ttsResp.ok) {
var errBody = await ttsResp.text();

View file

@ -7,7 +7,7 @@ var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { getSTTModelLists, getSTTProvider } = require('../utils/sttProvider');
var { getTTSProvider, getTTSVoiceLists } = require('../utils/ttsProvider');
var { getLiteLLMTTSVoicesForModel, getTTSProvider } = require('../utils/ttsProvider');
router.use(authMiddleware);
@ -49,14 +49,18 @@ router.get('/preferences/options', async function(req, res) {
var provider = getSTTProvider();
var ttsProvider = getTTSProvider();
var dbModel = await db.getSetting('tts.model') || '';
var dbVoice = await db.getSetting('tts.voice') || '';
var ttsModel = dbModel || process.env.LITELLM_TTS_MODEL || '';
var sttModels = getSTTModelLists().litellm.map(function(model) { return { value: model, label: model }; });
var ttsVoices = getTTSVoiceLists().litellm.map(function(voice) { return { value: voice, label: voice }; });
var ttsVoices = getLiteLLMTTSVoicesForModel(ttsModel, { currentVoice: dbVoice }).map(function(voice) { return { value: voice, label: voice }; });
res.json({
success: true,
sttProvider: provider || 'none',
sttModels: sttModels,
ttsProvider: ttsProvider || 'browser',
ttsModel: ttsModel || null,
ttsVoices: ttsVoices
});

View file

@ -7,6 +7,20 @@ 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'];
var GROQ_ORPHEUS_ENGLISH_VOICES = ['autumn', 'diana', 'hannah', 'austin', 'daniel', 'troy'];
var GROQ_ORPHEUS_ARABIC_VOICES = ['abdullah', 'fahad', 'sultan', 'lulwa', 'noura', 'aisha'];
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';
@ -20,10 +34,58 @@ function getTTSEnvProvider() {
function getTTSVoiceLists() {
return {
litellm: parseList(process.env.LITELLM_TTS_VOICES)
litellm: uniqueList(parseList(process.env.LITELLM_TTS_VOICES).concat(KITTEN_TTS_VOICES, SUPERTONIC_TTS_VOICES, 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';
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 (!voice) return true;
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;
}
return true;
}
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();
else if (family === 'groq-orpheus-arabic') voices = GROQ_ORPHEUS_ARABIC_VOICES.slice();
else voices = parseList(process.env.LITELLM_TTS_VOICES);
[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';
@ -56,6 +118,26 @@ 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' });
});
}
if (getLiteLLMTTSModels(models).indexOf('groq-orpheus-arabic-saudi') !== -1 || opts.currentModel === 'groq-orpheus-arabic-saudi') {
GROQ_ORPHEUS_ARABIC_VOICES.forEach(function(voice) {
pushUniqueTTSItem(items, { id: voice, name: 'Groq Orpheus Arabic ' + voice, source: 'groq-orpheus-arabic-saudi', kind: 'voice' });
});
}
return items;
}
@ -64,7 +146,10 @@ module.exports = {
getLiteLLMTTSDiscoveryItems,
getLiteLLMHeaders,
getLiteLLMTTSModels,
getLiteLLMTTSRequestOptions,
getLiteLLMTTSVoicesForModel,
getTTSProvider,
getTTSVoiceLists,
isLiteLLMTTSVoiceCompatible,
isLiteLLMTTSModel
};

View file

@ -1,7 +1,7 @@
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'];
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 = {};
@ -36,10 +36,11 @@ test('TTS provider ignores non-LiteLLM provider overrides', () => {
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' }, () => {
assert.deepEqual(ttsProvider.getTTSVoiceLists().litellm, [
'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');
assert.equal(voices.includes('Jasper'), true);
assert.equal(voices.includes('F1'), true);
});
});
@ -92,6 +93,19 @@ test('LiteLLM TTS discovery includes metadata models and configured fallbacks',
});
});
test('LiteLLM TTS discovery expands Kitten and Supertonic voices', () => {
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' } }
], {});
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);
});
});
test('LiteLLM TTS discovery still shows configured model if metadata lookup fails', () => {
const ttsProvider = require('../src/utils/ttsProvider');
assert.deepEqual(ttsProvider.getLiteLLMTTSDiscoveryItems([], {
@ -103,6 +117,38 @@ 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']);
});
});
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);
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' }, () => {