// Text-to-speech and speech-to-text settings panels. // Extracted verbatim from static/js/settings.js. Behaviour is unchanged: the // functions moved, their bodies did not. settings.js imports them and calls // them from the same places, so load order and init sequence are untouched. import { byId as el } from './dom.js'; import { postSettings as _postSettings } from './api.js'; export async function initTtsSettings() { var provSel = el('set-ttsProviderSelect'); var modelSelect = el('set-ttsModelSelect'); var modelInput = el('set-ttsModelInput'); var voiceSelect = el('set-ttsVoiceSelect'); var voiceInput = el('set-ttsVoiceInput'); var modelRow = el('set-ttsModelRow'); var voiceRow = el('set-ttsVoiceRow'); var speedSelect = el('set-ttsSpeedSelect'); var speedRow = el('set-ttsSpeedRow'); var ttsMsg = el('set-ttsSettingsMsg'); var ttsEnabledToggle = el('set-ttsEnabledToggle'); var ttsConfigWrap = provSel ? provSel.closest('div[style*="flex-direction"]') : null; function isEndpoint() { return provSel.value.startsWith('endpoint:'); } function getModel() { return isEndpoint() ? modelSelect.value : modelInput.value; } function getVoice() { return isEndpoint() ? voiceSelect.value : voiceInput.value; } function updateVisibility() { var prov = provSel.value; modelRow.style.display = prov.startsWith('endpoint:') ? 'flex' : 'none'; voiceRow.style.display = prov === 'disabled' ? 'none' : 'flex'; speedRow.style.display = prov === 'disabled' ? 'none' : 'flex'; if (isEndpoint()) { modelSelect.style.display = ''; modelInput.style.display = 'none'; voiceSelect.style.display = ''; voiceInput.style.display = 'none'; } else { modelSelect.style.display = 'none'; modelInput.style.display = ''; voiceSelect.style.display = 'none'; voiceInput.style.display = prov === 'disabled' ? 'none' : ''; } } var ttsKeywords = ['tts', 'audio']; try { var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); var endpoints = await epRes.json(); endpoints.forEach(function(ep) { if (!ep.is_enabled) return; var hasTTS = (ep.models || []).some(m => ttsKeywords.some(kw => m.toLowerCase().includes(kw))); if (!hasTTS) return; var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt); }); } catch (e) { console.warn('Failed to load endpoints for TTS', e); } try { var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); var settings = await settingsRes.json(); if (settings.tts_provider) provSel.value = settings.tts_provider; if (settings.tts_model) { modelSelect.value = settings.tts_model; modelInput.value = settings.tts_model; } if (settings.tts_voice) { voiceSelect.value = settings.tts_voice; voiceInput.value = settings.tts_voice; } if (settings.tts_speed) { speedSelect.value = settings.tts_speed; } if (ttsEnabledToggle) ttsEnabledToggle.checked = settings.tts_enabled !== false; } catch (e) { console.warn('Failed to load TTS settings', e); } function syncTtsDisabled() { var off = ttsEnabledToggle && !ttsEnabledToggle.checked; var card = ttsEnabledToggle ? ttsEnabledToggle.closest('.admin-card') : null; if (card) card.style.opacity = off ? '0.45' : ''; if (ttsConfigWrap) ttsConfigWrap.style.pointerEvents = off ? 'none' : ''; } syncTtsDisabled(); updateVisibility(); async function saveTTS() { try { await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }); ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000); if (window.aiTTSManager) window.aiTTSManager.checkAvailability(); } catch (e) { ttsMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; ttsMsg.style.color = 'var(--red)'; } } async function saveAndClearCache() { await saveTTS(); fetch('/api/tts/clear-cache', { method: 'POST', credentials: 'same-origin' }).catch(function(){}); } provSel.addEventListener('change', function() { var prov = provSel.value; if (prov === 'local') voiceInput.value = 'af_heart'; else if (isEndpoint()) { voiceSelect.value = 'alloy'; modelSelect.value = 'tts-1'; } else if (prov === 'browser') { voiceInput.value = ''; voiceInput.placeholder = 'OS default voice'; } updateVisibility(); saveTTS(); }); modelSelect.addEventListener('change', saveAndClearCache); modelInput.addEventListener('change', saveTTS); voiceSelect.addEventListener('change', saveAndClearCache); voiceInput.addEventListener('change', saveTTS); speedSelect.addEventListener('change', saveAndClearCache); if (ttsEnabledToggle) ttsEnabledToggle.addEventListener('change', function() { syncTtsDisabled(); saveTTS(); }); // Preview / test button var previewBtn = el('set-ttsPreviewBtn'); if (previewBtn) { var previewAudio = null; var previewPlaying = false; function resetPreview() { previewPlaying = false; previewBtn.textContent = 'Preview'; previewBtn.style.borderColor = ''; } previewBtn.addEventListener('click', async function() { if (previewPlaying) { if (previewAudio) { previewAudio.pause(); previewAudio = null; } window.speechSynthesis.cancel(); resetPreview(); return; } var prov = provSel.value; if (prov === 'disabled') { ttsMsg.textContent = 'Select a provider first'; ttsMsg.style.color = 'var(--red, #e55)'; setTimeout(function() { ttsMsg.textContent = ''; }, 2000); return; } var testText = 'Hello, this is a test of text to speech.'; previewPlaying = true; previewBtn.textContent = 'Loading...'; try { if (prov === 'browser') { if (!('speechSynthesis' in window)) throw new Error('Browser TTS not supported'); var utt = new SpeechSynthesisUtterance(testText); var voiceVal = getVoice(); if (voiceVal) { var voices = window.speechSynthesis.getVoices(); var target = voiceVal.toLowerCase(); var match = voices.find(function(v) { return v.name.toLowerCase() === target; }) || voices.find(function(v) { return v.name.toLowerCase().includes(target); }); if (match) utt.voice = match; } utt.rate = parseFloat(speedSelect.value) || 1; previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)'; await new Promise(function(resolve, reject) { utt.onend = resolve; utt.onerror = function(e) { reject(new Error('Browser TTS: ' + e.error)); }; window.speechSynthesis.speak(utt); }); } else { var res = await fetch('/api/tts/synthesize', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: testText, format: 'audio' }) }); if (!res.ok) { var err = await res.json().catch(function() { return {}; }); throw new Error(err.detail?.message || 'Synthesis failed'); } var blob = await res.blob(); var url = URL.createObjectURL(blob); previewAudio = new Audio(url); previewBtn.textContent = 'Stop'; previewBtn.style.borderColor = 'var(--red, #e55)'; await new Promise(function(resolve, reject) { previewAudio.onended = function() { URL.revokeObjectURL(url); previewAudio = null; resolve(); }; previewAudio.onerror = function() { URL.revokeObjectURL(url); previewAudio = null; reject(new Error('Playback failed')); }; previewAudio.play().catch(reject); }); } } catch (e) { ttsMsg.textContent = 'Preview failed: ' + e.message; ttsMsg.style.color = 'var(--red, #e55)'; setTimeout(function() { ttsMsg.textContent = ''; }, 3000); } finally { resetPreview(); } }); } } export async function initSttSettings() { var provSel = el('set-sttProviderSelect'); var modelSelect = el('set-sttModelSelect'); var modelInput = el('set-sttModelInput'); var modelRow = el('set-sttModelRow'); var langRow = el('set-sttLangRow'); var langInput = el('set-sttLangInput'); var sttMsg = el('set-sttSettingsMsg'); var sttEnabledToggle = el('set-sttEnabledToggle'); var sttConfigWrap = el('set-sttConfigWrap'); // STT was removed from AI Defaults — bail if the UI isn't present. if (!provSel) return; function isEndpoint() { return provSel.value.startsWith('endpoint:'); } function getModel() { return isEndpoint() ? modelInput.value : modelSelect.value; } function updateVisibility() { var prov = provSel.value; var showModel = prov === 'local' || prov.startsWith('endpoint:'); var showLang = prov !== 'disabled'; modelRow.style.display = showModel ? 'flex' : 'none'; langRow.style.display = showLang ? 'flex' : 'none'; if (isEndpoint()) { modelSelect.style.display = 'none'; modelInput.style.display = ''; } else { modelSelect.style.display = ''; modelInput.style.display = 'none'; } } function syncSttDisabled() { var off = sttEnabledToggle && !sttEnabledToggle.checked; var card = sttEnabledToggle ? sttEnabledToggle.closest('.admin-card') : null; if (card) card.style.opacity = off ? '0.45' : ''; if (sttConfigWrap) sttConfigWrap.style.pointerEvents = off ? 'none' : ''; } // Effective provider: if toggle is off, treat as disabled regardless of provider select function effectiveProvider() { if (sttEnabledToggle && !sttEnabledToggle.checked) return 'disabled'; return provSel.value; } // Add API endpoints that might support STT try { var epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); var endpoints = await epRes.json(); endpoints.forEach(function(ep) { if (!ep.is_enabled) return; var opt = document.createElement('option'); opt.value = 'endpoint:' + ep.id; opt.textContent = ep.name + ' (API)'; provSel.appendChild(opt); }); } catch (e) { console.warn('Failed to load endpoints for STT', e); } // Load saved settings try { var settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' }); var settings = await settingsRes.json(); if (settings.stt_provider) provSel.value = settings.stt_provider; if (settings.stt_model) { modelSelect.value = settings.stt_model; modelInput.value = settings.stt_model; } if (settings.stt_language) langInput.value = settings.stt_language; if (sttEnabledToggle) sttEnabledToggle.checked = settings.stt_enabled !== false; } catch (e) { console.warn('Failed to load STT settings', e); } syncSttDisabled(); updateVisibility(); async function saveSTT() { try { var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false; await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }); sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000); // Notify voiceRecorder of effective provider and update send button icon if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider(); if (window._updateSendBtnIcon) window._updateSendBtnIcon(); } catch (e) { sttMsg.textContent = e.status === 403 ? 'Admin access is required to change these settings.' : 'Failed to save'; sttMsg.style.color = 'var(--red)'; } } provSel.addEventListener('change', function() { updateVisibility(); saveSTT(); }); modelSelect.addEventListener('change', saveSTT); modelInput.addEventListener('change', saveSTT); langInput.addEventListener('change', saveSTT); if (sttEnabledToggle) sttEnabledToggle.addEventListener('change', function() { syncSttDisabled(); saveSTT(); }); }