diff --git a/public/components/settings.html b/public/components/settings.html index 9dcf50e..7df07fe 100644 --- a/public/components/settings.html +++ b/public/components/settings.html @@ -5,6 +5,35 @@
+ +
+

Voice Preferences

+

Customize your speech-to-text model and text-to-speech voice. These settings apply to all your recording and read-aloud features.

+ +
+ +

Choose the AI model for transcribing your audio recordings. More accurate models may be slower.

+ +
+ +
+ +

Choose the voice for the "Read Aloud" feature. Preview available after selection.

+
+ + +
+
+ +
+ +
+
+

Browser Transcription (Local Whisper)

diff --git a/public/index.html b/public/index.html index 17918e7..c2dd2ec 100644 --- a/public/index.html +++ b/public/index.html @@ -303,6 +303,7 @@ + diff --git a/public/js/voicePreferences.js b/public/js/voicePreferences.js new file mode 100644 index 0000000..ad39a1c --- /dev/null +++ b/public/js/voicePreferences.js @@ -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 = ''; + 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 = ''; + 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 = ' 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 = ' Preview'; + } + }); + } + +})(); diff --git a/server.js b/server.js index 59ca4af..e9f90e3 100644 --- a/server.js +++ b/server.js @@ -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) diff --git a/src/db/database.js b/src/db/database.js index ae42450..5480398 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -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) {} diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js index a4e45e8..bf7249e 100644 --- a/src/routes/transcribe.js +++ b/src/routes/transcribe.js @@ -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'; diff --git a/src/routes/tts.js b/src/routes/tts.js index e880534..338ec44 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -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; diff --git a/src/routes/userPreferences.js b/src/routes/userPreferences.js new file mode 100644 index 0000000..3d29ff5 --- /dev/null +++ b/src/routes/userPreferences.js @@ -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; diff --git a/src/utils/transcribeGoogle.js b/src/utils/transcribeGoogle.js index 5d9319e..2035c08 100644 --- a/src/utils/transcribeGoogle.js +++ b/src/utils/transcribeGoogle.js @@ -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'); diff --git a/src/utils/ttsGoogle.js b/src/utils/ttsGoogle.js index cd0e99f..f246c2b 100644 --- a/src/utils/ttsGoogle.js +++ b/src/utils/ttsGoogle.js @@ -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(