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(