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
63 lines
3 KiB
JavaScript
63 lines
3 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { authMiddleware } = require('../middleware/auth');
|
|
var logger = require('../utils/logger');
|
|
var { gatewayUrl } = require('../utils/errors');
|
|
var { getLiteLLMTTSRequestOptions, chooseTTS, getTTSProvider } = require('../utils/ttsProvider');
|
|
var { getLiteLLMHeaders } = require('../utils/litellm');
|
|
|
|
// TTS is intentionally routed only through LiteLLM. Provider-specific voice
|
|
// routing belongs in LiteLLM config, not this app.
|
|
var ttsProvider = getTTSProvider();
|
|
console.log('🔊 TTS provider:', ttsProvider);
|
|
|
|
router.post('/text-to-speech', authMiddleware, require('../utils/policy').requireFeature('read_aloud'), async (req, res) => {
|
|
try {
|
|
var text = (req.body.text || '').substring(0, 5000);
|
|
if (!text) return res.status(400).json({ error: 'No text provided' });
|
|
|
|
// The user's choice is "model|voice" from the roster; a bare voice is a
|
|
// choice saved before there was a roster. Either way chooseTTS decides,
|
|
// and it is the same decision the admin test and the settings page make.
|
|
var db = require('../db/database');
|
|
var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]);
|
|
|
|
if (ttsProvider !== 'litellm' || !process.env.LITELLM_API_BASE) {
|
|
return res.status(400).json({ error: 'TTS not configured. Set LITELLM_API_BASE.' });
|
|
}
|
|
var chosen = chooseTTS({
|
|
roster: String(await db.getSetting('tts.roster') || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean),
|
|
defaultModel: await db.getSetting('tts.model') || process.env.LITELLM_TTS_MODEL || '',
|
|
defaultVoice: await db.getSetting('tts.voice') || '',
|
|
envVoice: process.env.LITELLM_TTS_VOICE || '',
|
|
preferred: userPrefs?.tts_voice
|
|
});
|
|
var ttsModel = chosen.model;
|
|
var ttsVoice = chosen.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(payload)
|
|
});
|
|
if (!ttsResp.ok) {
|
|
var errBody = await ttsResp.text();
|
|
throw new Error('LiteLLM /audio/speech ' + ttsResp.status + ': ' + errBody.substring(0, 500));
|
|
}
|
|
var audioBuf = Buffer.from(await ttsResp.arrayBuffer());
|
|
res.set('Content-Type', ttsResp.headers.get('content-type') || 'audio/mpeg');
|
|
res.set('X-TTS-Provider', 'litellm/' + ttsModel);
|
|
logger.audit(req.user.id, 'text_to_speech', 'TTS generated', req, { category: 'clinical' });
|
|
return res.send(audioBuf);
|
|
} catch (err) {
|
|
var detail = err.response && err.response.data
|
|
? JSON.stringify(err.response.data).substring(0, 500)
|
|
: err.message;
|
|
console.error('[TTS] Error (' + ttsProvider + '):', detail);
|
|
res.status(500).json({ error: 'TTS failed: ' + detail });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|