🚀 Auto-deploy: Camila atualizado em 26/05/2026 14:20:35
This commit is contained in:
+69
-62
@@ -218,67 +218,87 @@ const initApp = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// TTS — Voz Neural Thalita via servidor (Microsoft Edge TTS)
|
// TTS — Speech Synthesis nativa (voz do navegador)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
let currentAudio = null; // controlar áudio em andamento
|
let cachedVoices = [];
|
||||||
let currentSpeakBtn = null;
|
|
||||||
|
|
||||||
// Limpar markdown e preparar texto para fala
|
const loadVoices = () => {
|
||||||
const cleanTextForSpeech = (text) => {
|
const voices = window.speechSynthesis.getVoices();
|
||||||
return text
|
if (voices.length > 0) {
|
||||||
.replace(/```[\s\S]*?```/g, '') // remover blocos de código
|
cachedVoices = voices;
|
||||||
.replace(/`[^`]+`/g, '') // remover inline code
|
console.log('Vozes pt-BR disponíveis:', cachedVoices.filter(v => v.lang.startsWith('pt')).map(v => v.name));
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // links → só texto
|
} else {
|
||||||
.replace(/[*_#~\[\]>]/g, '') // remover formatação
|
window.speechSynthesis.addEventListener('voiceschanged', () => {
|
||||||
.replace(/\n+/g, '. ') // quebras → pausas
|
cachedVoices = window.speechSynthesis.getVoices();
|
||||||
.replace(/\s{2,}/g, ' ')
|
console.log('Vozes pt-BR disponíveis:', cachedVoices.filter(v => v.lang.startsWith('pt')).map(v => v.name));
|
||||||
.trim();
|
}, { once: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadVoices();
|
||||||
|
|
||||||
|
// Selecionar melhor voz pt-BR disponível no dispositivo
|
||||||
|
const getBestVoice = () => {
|
||||||
|
const ptBR = cachedVoices.filter(v => v.lang === 'pt-BR' || v.lang === 'pt_BR');
|
||||||
|
if (ptBR.length === 0) {
|
||||||
|
const pt = cachedVoices.filter(v => v.lang.startsWith('pt'));
|
||||||
|
return pt[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Microsoft Neural (Edge): Thalita, Francisca, Giovanna
|
||||||
|
const msNeural = ptBR.find(v => /thalita|francisca|giovanna|natural|neural/i.test(v.name));
|
||||||
|
if (msNeural) return msNeural;
|
||||||
|
|
||||||
|
// 2. Google (Chrome/Android): vozes naturais
|
||||||
|
const google = ptBR.find(v => /google/i.test(v.name));
|
||||||
|
if (google) return google;
|
||||||
|
|
||||||
|
// 3. Apple (Safari/iOS): Luciana é excelente
|
||||||
|
const apple = ptBR.find(v => /luciana|fernanda/i.test(v.name));
|
||||||
|
if (apple) return apple;
|
||||||
|
|
||||||
|
// 4. Qualquer feminina
|
||||||
|
const female = ptBR.find(v => /female|femin|maria|raquel|camila/i.test(v.name));
|
||||||
|
if (female) return female;
|
||||||
|
|
||||||
|
// 5. Primeira disponível
|
||||||
|
return ptBR[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Falar texto usando TTS neural do servidor
|
// Falar texto com TTS nativa
|
||||||
const speakText = async (text, btn) => {
|
const speakText = (text, btn) => {
|
||||||
// Se já está reproduzindo, parar
|
// Se já está falando, parar
|
||||||
if (currentAudio && !currentAudio.paused) {
|
if (window.speechSynthesis.speaking) {
|
||||||
currentAudio.pause();
|
window.speechSynthesis.cancel();
|
||||||
currentAudio.currentTime = 0;
|
if (btn) {
|
||||||
currentAudio = null;
|
btn.classList.remove('speaking');
|
||||||
if (currentSpeakBtn) {
|
const span = btn.querySelector('span');
|
||||||
currentSpeakBtn.classList.remove('speaking');
|
|
||||||
const span = currentSpeakBtn.querySelector('span');
|
|
||||||
if (span) span.textContent = 'Ouvir';
|
if (span) span.textContent = 'Ouvir';
|
||||||
}
|
}
|
||||||
currentSpeakBtn = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clean = cleanTextForSpeech(text);
|
// Limpar markdown antes de falar
|
||||||
|
const clean = text
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/`[^`]+`/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/[*_#~\[\]>]/g, '')
|
||||||
|
.replace(/\n+/g, '. ')
|
||||||
|
.replace(/\s{2,}/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
|
|
||||||
// Feedback visual imediato
|
const utterance = new SpeechSynthesisUtterance(clean);
|
||||||
if (btn) {
|
utterance.lang = 'pt-BR';
|
||||||
btn.classList.add('speaking');
|
utterance.rate = 1.15;
|
||||||
const span = btn.querySelector('span');
|
utterance.pitch = 1.0;
|
||||||
if (span) span.textContent = 'Carregando...';
|
|
||||||
}
|
|
||||||
currentSpeakBtn = btn;
|
|
||||||
|
|
||||||
try {
|
const voice = getBestVoice();
|
||||||
const response = await fetch('/api/tts', {
|
if (voice) utterance.voice = voice;
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text: clean })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Erro no TTS');
|
utterance.onstart = () => {
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
currentAudio = new Audio(url);
|
|
||||||
currentAudio.playbackRate = 1.05; // levemente mais rápida
|
|
||||||
|
|
||||||
currentAudio.onplay = () => {
|
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.classList.add('speaking');
|
btn.classList.add('speaking');
|
||||||
const span = btn.querySelector('span');
|
const span = btn.querySelector('span');
|
||||||
@@ -286,28 +306,15 @@ const initApp = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
currentAudio.onended = currentAudio.onerror = () => {
|
utterance.onend = utterance.onerror = () => {
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.classList.remove('speaking');
|
btn.classList.remove('speaking');
|
||||||
const span = btn.querySelector('span');
|
const span = btn.querySelector('span');
|
||||||
if (span) span.textContent = 'Ouvir';
|
if (span) span.textContent = 'Ouvir';
|
||||||
}
|
}
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
currentAudio = null;
|
|
||||||
currentSpeakBtn = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await currentAudio.play();
|
window.speechSynthesis.speak(utterance);
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro TTS:', error);
|
|
||||||
if (btn) {
|
|
||||||
btn.classList.remove('speaking');
|
|
||||||
const span = btn.querySelector('span');
|
|
||||||
if (span) span.textContent = 'Ouvir';
|
|
||||||
}
|
|
||||||
currentSpeakBtn = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -343,66 +343,6 @@ app.post('/api/chat', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================================================
|
|
||||||
// TTS — Text-to-Speech com voz neural Microsoft (Thalita)
|
|
||||||
// ==========================================================================
|
|
||||||
|
|
||||||
const { MsEdgeTTS } = require('msedge-tts');
|
|
||||||
const TTS_VOICE = 'pt-BR-ThalitaMultilingualNeural';
|
|
||||||
const TTS_FORMAT = 'audio-24khz-96kbitrate-mono-mp3';
|
|
||||||
|
|
||||||
// Instância TTS reutilizável (inicializada no primeiro uso)
|
|
||||||
let ttsInstance = null;
|
|
||||||
const getTTS = async () => {
|
|
||||||
if (!ttsInstance) {
|
|
||||||
ttsInstance = new MsEdgeTTS();
|
|
||||||
await ttsInstance.setMetadata(TTS_VOICE, TTS_FORMAT);
|
|
||||||
}
|
|
||||||
return ttsInstance;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pré-inicializar TTS ao iniciar o servidor
|
|
||||||
getTTS().then(() => console.log(`TTS inicializado com voz: ${TTS_VOICE}`))
|
|
||||||
.catch(err => console.warn('Aviso: TTS não pôde ser pré-inicializado:', err.message));
|
|
||||||
|
|
||||||
app.post('/api/tts', requireAuth, async (req, res) => {
|
|
||||||
const { text } = req.body;
|
|
||||||
|
|
||||||
if (!text || typeof text !== 'string') {
|
|
||||||
return res.status(400).json({ error: 'Texto é obrigatório' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limitar tamanho do texto para evitar abusos
|
|
||||||
const cleanText = text.substring(0, 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tts = await getTTS();
|
|
||||||
const result = await tts.toStream(cleanText);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'audio/mpeg');
|
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
|
||||||
|
|
||||||
result.audioStream.pipe(res);
|
|
||||||
|
|
||||||
result.audioStream.on('error', (err) => {
|
|
||||||
console.error('Erro no stream TTS:', err);
|
|
||||||
// Tentar reinicializar a instância TTS na próxima vez
|
|
||||||
ttsInstance = null;
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({ error: 'Erro ao gerar áudio' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erro na rota TTS:', error);
|
|
||||||
// Reinicializar instância para a próxima tentativa
|
|
||||||
ttsInstance = null;
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({ error: 'Erro ao gerar áudio' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rota coringa para redirecionar para index.html
|
// Rota coringa para redirecionar para index.html
|
||||||
app.get('*', requireAuth, (req, res) => {
|
app.get('*', requireAuth, (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
|||||||
Reference in New Issue
Block a user