🔊 TTS Ouvir: botão em cada resposta da Kemily com Speech Synthesis nativo
This commit is contained in:
+100
@@ -90,6 +90,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let isGenerating = false;
|
let isGenerating = false;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let activeTag = 'all';
|
let activeTag = 'all';
|
||||||
|
let currentUtterance = null; // para controlar TTS em andamento
|
||||||
|
|
||||||
// Variáveis de Reconhecimento de Voz
|
// Variáveis de Reconhecimento de Voz
|
||||||
let recognition = null;
|
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
|
// ATALHOS DE TECLADO
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@@ -519,6 +604,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Regenerar</span>
|
<span>Regenerar</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="msg-action-btn btn-speak" title="Ouvir resposta" data-content="${escapeHtml(content).replace(/"/g, '"')}">
|
||||||
|
<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>
|
||||||
` : `
|
` : `
|
||||||
<div class="message-actions">
|
<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
|
// Event listener para regenerar resposta
|
||||||
const regenBtn = row.querySelector('.btn-regenerate');
|
const regenBtn = row.querySelector('.btn-regenerate');
|
||||||
if (regenBtn) {
|
if (regenBtn) {
|
||||||
|
|||||||
@@ -1255,6 +1255,28 @@ code {
|
|||||||
transition: transform 0.2s ease;
|
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 {
|
.disclaimer {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user