diff --git a/public/app.js b/public/app.js index 5f0783a..8984625 100644 --- a/public/app.js +++ b/public/app.js @@ -90,6 +90,7 @@ document.addEventListener('DOMContentLoaded', () => { let isGenerating = false; let searchQuery = ''; let activeTag = 'all'; + let currentUtterance = null; // para controlar TTS em andamento // Variáveis de Reconhecimento de Voz let recognition = null; @@ -216,6 +217,90 @@ document.addEventListener('DOMContentLoaded', () => { } }); + // ========================================================================== + // TTS — Speech Synthesis (ouvir respostas) + // ========================================================================== + + // Carregar vozes disponíveis (pode ser assíncrono em alguns navegadores) + const loadVoices = () => { + return new Promise((resolve) => { + const voices = window.speechSynthesis.getVoices(); + if (voices.length > 0) { + resolve(voices); + } else { + window.speechSynthesis.addEventListener('voiceschanged', () => { + resolve(window.speechSynthesis.getVoices()); + }, { once: true }); + } + }); + }; + + let cachedVoices = []; + loadVoices().then(voices => { cachedVoices = voices; }); + + // Escolher melhor voz feminina brasileira disponível + const getBestBRVoice = () => { + const brVoices = cachedVoices.filter(v => v.lang.includes('pt')); + // Prioridade: feminina > masculino, local > remote + const female = brVoices.find(v => + /female|brasil|maria|luciana|fernanda|virtual/i.test(v.name) + ); + if (female) return female; + // fallback: qualquer voz brasileira + const brazil = brVoices.find(v => /brasil|brazil|pt-BR/i.test(v.lang)); + if (brazil) return brazil; + // fallback: qualquer voz portuguesa + return brVoices[0] || null; + }; + + // Falar texto com TTS nativo + const speakText = (text, btn) => { + // Se já está falando, parar + if (window.speechSynthesis.speaking) { + window.speechSynthesis.cancel(); + if (btn) { + btn.classList.remove('speaking'); + const span = btn.querySelector('span'); + if (span) span.textContent = 'Ouvir'; + } + return; + } + + // Limpar markdown básico antes de falar + const clean = text + .replace(/```[\s\S]*?```/g, 'trecho de código ignorado') + .replace(/`[^`]+`/g, '') + .replace(/[*_#~\[\]]/g, '') + .replace(/\n+/g, ' ') + .trim(); + + const utterance = new window.SpeechSynthesisUtterance(clean); + utterance.lang = 'pt-BR'; + utterance.rate = 0.95; + utterance.pitch = 1.05; + + const voice = getBestBRVoice(); + if (voice) utterance.voice = voice; + + utterance.onstart = () => { + if (btn) { + btn.classList.add('speaking'); + const span = btn.querySelector('span'); + if (span) span.textContent = 'Parar'; + } + }; + + utterance.onend = utterance.onerror = () => { + if (btn) { + btn.classList.remove('speaking'); + const span = btn.querySelector('span'); + if (span) span.textContent = 'Ouvir'; + } + }; + + window.speechSynthesis.speak(utterance); + }; + // ========================================================================== // ATALHOS DE TECLADO // ========================================================================== @@ -519,6 +604,12 @@ document.addEventListener('DOMContentLoaded', () => { Regenerar + ` : `
@@ -602,6 +693,15 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Event listener para ouvir resposta (TTS nativo) + const speakBtn = row.querySelector('.btn-speak'); + if (speakBtn) { + speakBtn.addEventListener('click', () => { + const text = speakBtn.dataset.content; + speakText(text, speakBtn); + }); + } + // Event listener para regenerar resposta const regenBtn = row.querySelector('.btn-regenerate'); if (regenBtn) { diff --git a/public/style.css b/public/style.css index f1909db..fc6a350 100644 --- a/public/style.css +++ b/public/style.css @@ -1255,6 +1255,28 @@ code { transition: transform 0.2s ease; } +.msg-action-btn.btn-speak svg { + transition: transform 0.2s ease; +} + +.msg-action-btn.btn-speak:hover { + color: #8b5cf6 !important; +} + +.msg-action-btn.btn-speak:hover svg { + transform: scale(1.1); +} + +.msg-action-btn.btn-speak.speaking { + color: #8b5cf6 !important; + animation: pulse-speak 1.5s ease-in-out infinite; +} + +@keyframes pulse-speak { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + .disclaimer { font-size: 11px; color: var(--text-muted);