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
218 lines
7.3 KiB
JavaScript
218 lines
7.3 KiB
JavaScript
// ============================================================
|
|
// VOICE PREFERENCES — STT Model & TTS Voice selection
|
|
// ============================================================
|
|
|
|
var _inited = false;
|
|
|
|
// Listen for tab changes (correct event name is 'tabChanged' not 'tab-loaded')
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail.tab !== 'settings' || _inited) return;
|
|
_inited = true;
|
|
console.log('[VoicePrefs] Initializing on settings tab load...');
|
|
// Small delay to ensure DOM is ready
|
|
setTimeout(function() {
|
|
initVoicePreferences();
|
|
}, 100);
|
|
});
|
|
|
|
// Also init on DOMContentLoaded as backup for direct page load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
setTimeout(function() {
|
|
if (!_inited && document.getElementById('btn-preview-voice')) {
|
|
console.log('[VoicePrefs] Init via DOMContentLoaded (direct settings page load)');
|
|
_inited = true;
|
|
initVoicePreferences();
|
|
}
|
|
}, 500);
|
|
});
|
|
} else {
|
|
// Page already loaded, init immediately if on settings
|
|
setTimeout(function() {
|
|
if (!_inited && document.getElementById('btn-preview-voice')) {
|
|
console.log('[VoicePrefs] Init immediate (page already loaded)');
|
|
_inited = true;
|
|
initVoicePreferences();
|
|
}
|
|
}, 500);
|
|
}
|
|
|
|
function initVoicePreferences() {
|
|
console.log('[VoicePrefs] initVoicePreferences called');
|
|
|
|
loadVoiceOptions();
|
|
loadUserPreferences();
|
|
|
|
// Save button
|
|
var btnSave = document.getElementById('btn-save-voice-prefs');
|
|
if (btnSave) {
|
|
console.log('[VoicePrefs] Save button found, adding listener');
|
|
btnSave.addEventListener('click', saveVoicePreferences);
|
|
} else {
|
|
console.warn('[VoicePrefs] Save button NOT found');
|
|
}
|
|
|
|
// Preview button
|
|
var btnPreview = document.getElementById('btn-preview-voice');
|
|
if (btnPreview) {
|
|
console.log('[VoicePrefs] Preview button found, adding listener');
|
|
btnPreview.addEventListener('click', function(e) {
|
|
console.log('[VoicePrefs] Preview button clicked!');
|
|
e.preventDefault();
|
|
previewVoice();
|
|
});
|
|
} else {
|
|
console.warn('[VoicePrefs] Preview button NOT found');
|
|
}
|
|
}
|
|
|
|
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, grouped by the model each belongs to. A voice's
|
|
// value is "model|voice": the server needs both to send the request to
|
|
// the model that accepts that voice.
|
|
var ttsSelect = document.getElementById('tts-voice-select');
|
|
if (ttsSelect && data.ttsVoices && data.ttsVoices.length > 0) {
|
|
ttsSelect.innerHTML = '<option value="">Server default (' + data.ttsProvider + ')</option>';
|
|
var groups = {};
|
|
data.ttsVoices.forEach(function(voice) {
|
|
var parent = ttsSelect;
|
|
if (voice.model) {
|
|
if (!groups[voice.model]) {
|
|
groups[voice.model] = document.createElement('optgroup');
|
|
groups[voice.model].label = voice.model;
|
|
ttsSelect.appendChild(groups[voice.model]);
|
|
}
|
|
parent = groups[voice.model];
|
|
}
|
|
var opt = document.createElement('option');
|
|
opt.value = voice.value;
|
|
opt.textContent = voice.label;
|
|
parent.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;
|
|
|
|
// Allow "Server default" (empty value) to preview
|
|
var displayVoice = voice ? voice.split('|').pop() : 'server default';
|
|
var text = 'Hello, this is a preview of the ' + displayVoice + ' 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 (or clear it if "server default" selected)
|
|
fetch('/api/user/preferences', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ tts_voice: voice || null })
|
|
})
|
|
.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: ' + displayVoice, 'success');
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[VoicePrefs] Preview error:', err);
|
|
showToast('Preview failed: ' + err.message, 'error');
|
|
})
|
|
.finally(function() {
|
|
if (btnPreview) {
|
|
btnPreview.disabled = false;
|
|
btnPreview.innerHTML = '<i class="fas fa-play"></i> Preview';
|
|
}
|
|
});
|
|
}
|