Add per-user voice preferences (STT model + TTS voice selection)

- NEW: User preferences for STT model and TTS voice
- Database: stt_model and tts_voice columns in users table
- UI: Voice Preferences section in Settings with dropdowns
- API: /api/user/preferences (GET/POST) + /preferences/options
- Transcribe: Respects user's STT model (Google, LiteLLM)
- TTS: Respects user's TTS voice (Google, LiteLLM, OpenAI, ElevenLabs)
- Preview: Test TTS voice before saving
- Available models/voices auto-detected from provider config
This commit is contained in:
ifedan-ed 2026-03-31 14:47:00 +00:00
parent 9740650fbf
commit fb53aa709f
10 changed files with 389 additions and 12 deletions

View file

@ -5,6 +5,35 @@
<div class="settings-page">
<!-- Voice Preferences (STT & TTS) -->
<div class="settings-section card" id="voice-preferences-section">
<h3><i class="fas fa-microphone-lines"></i> Voice Preferences</h3>
<p style="font-size:13px;color:var(--g600);">Customize your speech-to-text model and text-to-speech voice. These settings apply to all your recording and read-aloud features.</p>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Speech-to-Text Model (Transcription)</label>
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the AI model for transcribing your audio recordings. More accurate models may be slower.</p>
<select id="stt-model-select" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;width:100%;max-width:400px;">
<option value="">Server default</option>
</select>
</div>
<div style="margin-bottom:16px;">
<label style="display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:6px;">Text-to-Speech Voice (Read Aloud)</label>
<p style="font-size:12px;color:var(--g500);margin:4px 0 8px;">Choose the voice for the "Read Aloud" feature. Preview available after selection.</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<select id="tts-voice-select" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;">
<option value="">Server default</option>
</select>
<button id="btn-preview-voice" class="btn-sm btn-ghost"><i class="fas fa-play"></i> Preview</button>
</div>
</div>
<div style="margin-top:12px;">
<button id="btn-save-voice-prefs" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save Voice Preferences</button>
</div>
</div>
<!-- Browser Whisper -->
<div class="settings-section card" id="browser-whisper-section">
<h3><i class="fas fa-microchip"></i> Browser Transcription (Local Whisper)</h3>

View file

@ -303,6 +303,7 @@
<script defer src="/js/audioBackup.js"></script>
<script defer src="/js/correctionTracker.js"></script>
<script defer src="/js/browserWhisper.js"></script>
<script defer src="/js/voicePreferences.js"></script>
<script defer src="/js/app.js"></script>
<script defer src="/js/auth.js"></script>
<script defer src="/js/liveEncounter.js"></script>

View file

@ -0,0 +1,173 @@
// ============================================================
// VOICE PREFERENCES — STT Model & TTS Voice selection
// ============================================================
(function() {
var _inited = false;
window.addEventListener('tab-loaded', function(e) {
if (e.detail.tab !== 'settings' || _inited) return;
_inited = true;
initVoicePreferences();
});
function initVoicePreferences() {
loadVoiceOptions();
loadUserPreferences();
// Save button
var btnSave = document.getElementById('btn-save-voice-prefs');
if (btnSave) {
btnSave.addEventListener('click', saveVoicePreferences);
}
// Preview button
var btnPreview = document.getElementById('btn-preview-voice');
if (btnPreview) {
btnPreview.addEventListener('click', previewVoice);
}
}
function loadVoiceOptions() {
fetch('/api/user/preferences/options', {
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
// Populate STT models
var sttSelect = document.getElementById('stt-model-select');
if (sttSelect && data.sttModels && data.sttModels.length > 0) {
sttSelect.innerHTML = '<option value="">Server default (' + data.sttProvider + ')</option>';
data.sttModels.forEach(function(model) {
var opt = document.createElement('option');
opt.value = model.value;
opt.textContent = model.label;
sttSelect.appendChild(opt);
});
}
// Populate TTS voices
var ttsSelect = document.getElementById('tts-voice-select');
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
data.ttsVoices.forEach(function(voice) {
var opt = document.createElement('option');
opt.value = voice.value;
opt.textContent = voice.label;
ttsSelect.appendChild(opt);
});
}
})
.catch(function(err) {
console.error('[VoicePrefs] Failed to load options:', err.message);
});
}
function loadUserPreferences() {
fetch('/api/user/preferences', {
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var sttSelect = document.getElementById('stt-model-select');
if (sttSelect && data.stt_model) {
sttSelect.value = data.stt_model;
}
var ttsSelect = document.getElementById('tts-voice-select');
if (ttsSelect && data.tts_voice) {
ttsSelect.value = data.tts_voice;
}
})
.catch(function(err) {
console.error('[VoicePrefs] Failed to load preferences:', err.message);
});
}
function saveVoicePreferences() {
var sttSelect = document.getElementById('stt-model-select');
var ttsSelect = document.getElementById('tts-voice-select');
var sttModel = sttSelect ? sttSelect.value : null;
var ttsVoice = ttsSelect ? ttsSelect.value : null;
fetch('/api/user/preferences', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
stt_model: sttModel || null,
tts_voice: ttsVoice || null
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Voice preferences saved!', 'success');
} else {
showToast(data.error || 'Failed to save', 'error');
}
})
.catch(function(err) {
showToast('Save failed: ' + err.message, 'error');
});
}
function previewVoice() {
var ttsSelect = document.getElementById('tts-voice-select');
var voice = ttsSelect ? ttsSelect.value : null;
if (!voice) {
showToast('Select a voice first', 'info');
return;
}
// Temporarily save preference, generate preview, then restore
var text = 'Hello, this is a preview of the ' + voice + ' voice. This is how your read-aloud feature will sound.';
var btnPreview = document.getElementById('btn-preview-voice');
if (btnPreview) {
btnPreview.disabled = true;
btnPreview.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...';
}
// Save current preference temporarily
fetch('/api/user/preferences', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ tts_voice: voice })
})
.then(function() {
// Generate audio with new voice
return fetch('/api/text-to-speech', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ text: text })
});
})
.then(function(r) {
if (!r.ok) throw new Error('Preview failed');
return r.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var audio = new Audio(url);
audio.onended = function() { URL.revokeObjectURL(url); };
audio.play();
showToast('Preview: ' + voice, 'success');
})
.catch(function(err) {
showToast('Preview failed: ' + err.message, 'error');
})
.finally(function() {
if (btnPreview) {
btnPreview.disabled = false;
btnPreview.innerHTML = '<i class="fas fa-play"></i> Preview';
}
});
}
})();

View file

@ -179,6 +179,7 @@ app.use('/api', require('./src/routes/documents'));
app.use('/api', require('./src/routes/audioBackups'));
app.use('/api', require('./src/routes/wellVisit'));
app.use('/api', require('./src/routes/sickVisit'));
app.use('/api/user', require('./src/routes/userPreferences'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));
// User-level preference: save WebDAV learning path (auth only, not moderator-only)

View file

@ -222,6 +222,9 @@ async function initDatabase() {
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
// Add webdav_learning_path to users for Learning Hub file browser
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
// Add user preferences for STT model and TTS voice
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS tts_voice TEXT DEFAULT NULL"); } catch(e) {}
// Add idempotency_key for duplicate prevention
try { await client.query("ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT"); } catch(e) {}
try { await client.query("CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL"); } catch(e) {}

View file

@ -55,12 +55,19 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
if (!req.file) return res.status(400).json({ error: 'No audio' });
var startTime = Date.now();
var fileSize = req.file.size;
console.log('[Transcribe] Received ' + (fileSize / 1024).toFixed(0) + 'KB audio (' + (req.file.mimetype || 'unknown') + ') via ' + provider);
// Get user's preferred STT model (if set)
var db = require('../db/database');
var userPrefs = await db.get('SELECT stt_model FROM users WHERE id = ?', [req.user.id]);
var userModel = userPrefs?.stt_model;
console.log('[Transcribe] Received ' + (fileSize / 1024).toFixed(0) + 'KB audio (' + (req.file.mimetype || 'unknown') + ') via ' + provider + (userModel ? ' (user model: ' + userModel + ')' : ''));
if (provider === 'google') {
var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm');
console.log('[Transcribe] Google/Gemini done in ' + (Date.now() - startTime) + 'ms');
return res.json({ success: true, text: text, provider: 'google-gemini', duration: Date.now() - startTime });
var model = userModel || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash';
var text = await transcribeWithGemini(req.file.buffer, req.file.mimetype || 'audio/webm', model);
console.log('[Transcribe] Google/' + model + ' done in ' + (Date.now() - startTime) + 'ms');
return res.json({ success: true, text: text, provider: 'google-' + model, duration: Date.now() - startTime });
}
if (provider === 'local') {
@ -81,7 +88,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r
// LiteLLM /audio/transcriptions does NOT support Vertex AI Chirp (Unmapped provider error).
// Instead, use /v1/chat/completions with a Gemini model — Gemini understands audio natively
// via base64 inline data. Set LITELLM_STT_MODEL to your Gemini model name in LiteLLM.
var sttModel = process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
var sttModel = userModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash';
var axios = require('axios');
var base64audio = req.file.buffer.toString('base64');
var mimeType = req.file.mimetype || 'audio/webm';

View file

@ -31,10 +31,16 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
var text = (req.body.text || '').substring(0, 5000);
if (!text) return res.status(400).json({ error: 'No text provided' });
// Get user's preferred TTS voice (if set)
var db = require('../db/database');
var userPrefs = await db.get('SELECT tts_voice FROM users WHERE id = ?', [req.user.id]);
var userVoice = userPrefs?.tts_voice;
if (ttsProvider === 'google') {
var buffer = await synthesizeWithGoogleTTS(text);
var voice = userVoice || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
var buffer = await synthesizeWithGoogleTTS(text, voice);
res.set('Content-Type', 'audio/mpeg');
res.set('X-TTS-Provider', 'google-tts');
res.set('X-TTS-Provider', 'google-tts/' + voice);
return res.send(buffer);
}
@ -45,7 +51,7 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => {
// Prefix with openai/ so LiteLLM routes correctly to the TTS endpoint
var rawModel = process.env.LITELLM_TTS_MODEL || 'tts-1';
var ttsModel = rawModel.indexOf('/') === -1 ? 'openai/' + rawModel : rawModel;
var ttsVoice = process.env.LITELLM_TTS_VOICE || 'en-US-Journey-F';
var ttsVoice = userVoice || process.env.LITELLM_TTS_VOICE || 'alloy';
var base = process.env.LITELLM_API_BASE.replace(/\/+$/, '');
var ttsHeaders = { 'Content-Type': 'application/json' };
if (process.env.LITELLM_API_KEY) ttsHeaders['Authorization'] = 'Bearer ' + process.env.LITELLM_API_KEY;

View file

@ -0,0 +1,157 @@
// ============================================================
// USER PREFERENCES — STT model & TTS voice selection
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
router.use(authMiddleware);
// Get current user's STT/TTS preferences
router.get('/preferences', async function(req, res) {
try {
var user = await db.get('SELECT stt_model, tts_voice FROM users WHERE id = ?', [req.user.id]);
res.json({
success: true,
stt_model: user?.stt_model || null,
tts_voice: user?.tts_voice || null
});
} catch (err) {
console.error('[Preferences]', err.message);
res.status(500).json({ error: err.message });
}
});
// Update user's STT/TTS preferences
router.post('/preferences', async function(req, res) {
try {
var { stt_model, tts_voice } = req.body;
await db.run(
'UPDATE users SET stt_model = ?, tts_voice = ? WHERE id = ?',
[stt_model || null, tts_voice || null, req.user.id]
);
res.json({ success: true });
} catch (err) {
console.error('[Preferences]', err.message);
res.status(500).json({ error: err.message });
}
});
// Get available STT models and TTS voices
router.get('/preferences/options', async function(req, res) {
try {
var provider = process.env.TRANSCRIBE_PROVIDER;
var ttsProvider = process.env.TTS_PROVIDER;
// Auto-detect providers if not explicitly set
if (!provider) {
if (process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT) provider = 'google';
else if (process.env.AWS_BEDROCK_REGION) provider = 'aws';
else if (process.env.LITELLM_API_BASE) provider = 'litellm';
else if (process.env.OPENAI_API_KEY) provider = 'openai';
}
if (!ttsProvider) {
if (process.env.VERTEX_PROJECT || process.env.GOOGLE_CLOUD_PROJECT) ttsProvider = 'google';
else if (process.env.LITELLM_API_BASE) ttsProvider = 'litellm';
else if (process.env.OPENAI_API_KEY) ttsProvider = 'openai';
else if (process.env.ELEVENLABS_API_KEY) ttsProvider = 'elevenlabs';
}
// STT Models
var sttModels = [];
if (provider === 'google') {
sttModels = [
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash (Experimental, fastest)' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (Fast, accurate)' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash (Stable)' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro (Best quality, slower)' }
];
} else if (provider === 'litellm') {
sttModels = [
{ value: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash Exp' },
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
{ value: 'whisper-1', label: 'OpenAI Whisper-1' },
{ value: 'whisper-large-v3', label: 'Whisper Large v3' }
];
} else if (provider === 'openai') {
sttModels = [
{ value: 'whisper-1', label: 'Whisper-1 (OpenAI standard)' }
];
} else if (provider === 'aws') {
sttModels = [
{ value: 'default', label: 'AWS Transcribe (Standard)' },
{ value: 'medical', label: 'AWS Transcribe Medical' }
];
} else if (provider === 'local') {
sttModels = [
{ value: 'tiny', label: 'Whisper Tiny (fastest)' },
{ value: 'base', label: 'Whisper Base' },
{ value: 'small', label: 'Whisper Small' },
{ value: 'medium', label: 'Whisper Medium' },
{ value: 'large', label: 'Whisper Large (best quality)' }
];
}
// TTS Voices
var ttsVoices = [];
if (ttsProvider === 'google') {
ttsVoices = [
{ value: 'en-US-Journey-F', label: 'Journey (Female, natural)' },
{ value: 'en-US-Journey-D', label: 'Journey (Male, natural)' },
{ value: 'en-US-Studio-O', label: 'Studio O (Female, expressive)' },
{ value: 'en-US-Studio-M', label: 'Studio M (Male, expressive)' },
{ value: 'en-US-Neural2-A', label: 'Neural2 A (Male, standard)' },
{ value: 'en-US-Neural2-C', label: 'Neural2 C (Female, standard)' },
{ value: 'en-US-Neural2-D', label: 'Neural2 D (Male, standard)' },
{ value: 'en-US-Neural2-E', label: 'Neural2 E (Female, standard)' },
{ value: 'en-US-Neural2-F', label: 'Neural2 F (Female, standard)' },
{ value: 'en-US-Neural2-G', label: 'Neural2 G (Female, standard)' },
{ value: 'en-US-Neural2-H', label: 'Neural2 H (Female, standard)' },
{ value: 'en-US-Neural2-I', label: 'Neural2 I (Male, standard)' },
{ value: 'en-US-Neural2-J', label: 'Neural2 J (Male, standard)' }
];
} else if (ttsProvider === 'litellm' || ttsProvider === 'openai') {
ttsVoices = [
{ value: 'alloy', label: 'Alloy (Neutral)' },
{ value: 'echo', label: 'Echo (Male)' },
{ value: 'fable', label: 'Fable (British Male)' },
{ value: 'onyx', label: 'Onyx (Deep Male)' },
{ value: 'nova', label: 'Nova (Female)' },
{ value: 'shimmer', label: 'Shimmer (Soft Female)' }
];
} else if (ttsProvider === 'elevenlabs') {
ttsVoices = [
{ value: 'adam', label: 'Adam (Male, deep)' },
{ value: 'rachel', label: 'Rachel (Female, calm)' },
{ value: 'domi', label: 'Domi (Female, strong)' },
{ value: 'bella', label: 'Bella (Female, soft)' },
{ value: 'antoni', label: 'Antoni (Male, deep)' },
{ value: 'elli', label: 'Elli (Female, young)' },
{ value: 'josh', label: 'Josh (Male, narration)' },
{ value: 'arnold', label: 'Arnold (Male, crisp)' },
{ value: 'sam', label: 'Sam (Male, raspy)' }
];
}
res.json({
success: true,
sttProvider: provider || 'none',
sttModels: sttModels,
ttsProvider: ttsProvider || 'browser',
ttsVoices: ttsVoices
});
} catch (err) {
console.error('[Preferences]', err.message);
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View file

@ -5,7 +5,7 @@
// Requires: GOOGLE_VERTEX_PROJECT, @google-cloud/vertexai installed
// ============================================================
async function transcribeWithGemini(buffer, mimeType) {
async function transcribeWithGemini(buffer, mimeType, modelName) {
var VertexModule = require('@google-cloud/vertexai');
var project = process.env.GOOGLE_VERTEX_PROJECT;
var location = process.env.GOOGLE_VERTEX_LOCATION || 'us-central1';
@ -13,7 +13,7 @@ async function transcribeWithGemini(buffer, mimeType) {
var vertex = new VertexModule.VertexAI({ project: project, location: location });
var model = vertex.getGenerativeModel({
model: process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash'
model: modelName || process.env.GOOGLE_STT_MODEL || 'gemini-2.0-flash'
});
var base64 = buffer.toString('base64');

View file

@ -6,7 +6,7 @@
// Requires: GOOGLE_VERTEX_PROJECT, ADC or GOOGLE_APPLICATION_CREDENTIALS
// ============================================================
async function synthesizeWithGoogleTTS(text) {
async function synthesizeWithGoogleTTS(text, voiceName) {
var GoogleAuth = require('google-auth-library').GoogleAuth;
var axios = require('axios');
@ -17,7 +17,7 @@ async function synthesizeWithGoogleTTS(text) {
var client = await auth.getClient();
var tokenData = await client.getAccessToken();
var token = tokenData.token;
var voice = process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
var voice = voiceName || process.env.GOOGLE_TTS_VOICE || 'en-US-Journey-F';
var langCode = voice.substring(0, 5); // e.g. en-US
var resp = await axios.post(