🔊 TTS Ouvir: botão em cada resposta da Kemily com Speech Synthesis nativo

This commit is contained in:
2026-05-25 23:43:02 +00:00
parent 5b6585f1b3
commit e1e3e422de
2 changed files with 122 additions and 0 deletions
+100
View File
@@ -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', () => {
</svg>
<span>Regenerar</span>
</button>
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${escapeHtml(content).replace(/"/g, '&quot;')}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
<span>Ouvir</span>
</button>
</div>
` : `
<div class="message-actions">
@@ -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) {
+22
View File
@@ -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);