diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml index 756f7ba..83c880a 100644 --- a/mobile/android/app/src/main/res/values/styles.xml +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -13,7 +13,7 @@ @@ -22,4 +22,4 @@ - \ No newline at end of file + diff --git a/public/audio/cardiac/functional-murmur.wav b/public/audio/cardiac/functional-murmur.wav index 4c0753a..f5a5a08 100644 Binary files a/public/audio/cardiac/functional-murmur.wav and b/public/audio/cardiac/functional-murmur.wav differ diff --git a/public/audio/cardiac/infant-normal.ogg b/public/audio/cardiac/infant-normal.ogg index 96d1a40..41dc1f4 100644 Binary files a/public/audio/cardiac/infant-normal.ogg and b/public/audio/cardiac/infant-normal.ogg differ diff --git a/public/audio/cardiac/mitral-prolapse.wav b/public/audio/cardiac/mitral-prolapse.wav index 2227290..03eff0f 100644 Binary files a/public/audio/cardiac/mitral-prolapse.wav and b/public/audio/cardiac/mitral-prolapse.wav differ diff --git a/public/audio/cardiac/normal.ogg b/public/audio/cardiac/normal.ogg index b46451d..3b02cb6 100644 Binary files a/public/audio/cardiac/normal.ogg and b/public/audio/cardiac/normal.ogg differ diff --git a/public/audio/cardiac/stills-murmur.ogg b/public/audio/cardiac/stills-murmur.ogg index a925616..21b48a4 100644 Binary files a/public/audio/cardiac/stills-murmur.ogg and b/public/audio/cardiac/stills-murmur.ogg differ diff --git a/public/audio/cardiac/vsd.wav b/public/audio/cardiac/vsd.wav index 97265e4..8239ed6 100644 Binary files a/public/audio/cardiac/vsd.wav and b/public/audio/cardiac/vsd.wav differ diff --git a/public/audio/respiratory/crackles-coarse.ogg b/public/audio/respiratory/crackles-coarse.ogg index fcc5502..4eba611 100644 Binary files a/public/audio/respiratory/crackles-coarse.ogg and b/public/audio/respiratory/crackles-coarse.ogg differ diff --git a/public/audio/respiratory/crackles-fine.ogg b/public/audio/respiratory/crackles-fine.ogg index c582c4d..cfdd3fd 100644 Binary files a/public/audio/respiratory/crackles-fine.ogg and b/public/audio/respiratory/crackles-fine.ogg differ diff --git a/public/audio/respiratory/normal-vesicular.ogg b/public/audio/respiratory/normal-vesicular.ogg index e8b044f..396dff1 100644 Binary files a/public/audio/respiratory/normal-vesicular.ogg and b/public/audio/respiratory/normal-vesicular.ogg differ diff --git a/public/audio/respiratory/pleural-rub.ogg b/public/audio/respiratory/pleural-rub.ogg index 64ada22..d98c74b 100644 Binary files a/public/audio/respiratory/pleural-rub.ogg and b/public/audio/respiratory/pleural-rub.ogg differ diff --git a/public/audio/respiratory/rhonchi.ogg b/public/audio/respiratory/rhonchi.ogg index 8f4af59..a518f3a 100644 Binary files a/public/audio/respiratory/rhonchi.ogg and b/public/audio/respiratory/rhonchi.ogg differ diff --git a/public/audio/respiratory/stridor.ogg b/public/audio/respiratory/stridor.ogg index 5fe7026..f1dfd4c 100644 Binary files a/public/audio/respiratory/stridor.ogg and b/public/audio/respiratory/stridor.ogg differ diff --git a/public/audio/respiratory/wheeze.ogg b/public/audio/respiratory/wheeze.ogg index adfde87..d984328 100644 Binary files a/public/audio/respiratory/wheeze.ogg and b/public/audio/respiratory/wheeze.ogg differ diff --git a/public/components/wellvisit.html b/public/components/wellvisit.html index cfd2291..f563c0d 100644 --- a/public/components/wellvisit.html +++ b/public/components/wellvisit.html @@ -11,6 +11,9 @@ + @@ -119,6 +122,65 @@ + + + - diff --git a/public/css/styles.css b/public/css/styles.css index cddc8fb..0bf15b4 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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;} diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index d15f2b8..0824d87 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -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); diff --git a/src/routes/tts.js b/src/routes/tts.js index e91494b..e03e2e4 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -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(); diff --git a/src/routes/userPreferences.js b/src/routes/userPreferences.js index d61abbc..e1f1747 100644 --- a/src/routes/userPreferences.js +++ b/src/routes/userPreferences.js @@ -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 }); diff --git a/src/utils/ttsProvider.js b/src/utils/ttsProvider.js index eb7534c..4846864 100644 --- a/src/utils/ttsProvider.js +++ b/src/utils/ttsProvider.js @@ -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 }; diff --git a/test/tts-provider.test.js b/test/tts-provider.test.js index 5d5696b..c547589 100644 --- a/test/tts-provider.test.js +++ b/test/tts-provider.test.js @@ -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' }, () => {