Diffusers
MuseTalk1.5 / server /static /index.html
Marcos
Add speech-to-speech avatar server with real-time video generation
c636b2c
Raw
History Blame Contribute Delete
14.9 kB
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MuseTalk - Avatar Speech-to-Speech</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #fff;
}
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
header { text-align: center; padding: 20px 0; }
header h1 {
font-size: 1.8rem;
background: linear-gradient(90deg, #00d4ff, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
header p { color: #888; font-size: 0.85rem; margin-top: 5px; }
.main-content { display: flex; flex-direction: column; gap: 20px; }
.avatar-section {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.avatar-container {
display: flex;
align-items: center;
gap: 20px;
}
.avatar-image {
width: 120px;
height: 120px;
border-radius: 50%;
background: linear-gradient(135deg, #7c3aed, #00d4ff);
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
flex-shrink: 0;
}
.avatar-image.speaking {
animation: speaking-pulse 0.5s infinite;
}
@keyframes speaking-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.5); }
50% { transform: scale(1.05); box-shadow: 0 0 20px 10px rgba(124, 58, 237, 0.3); }
}
.response-area { flex: 1; }
.response-text {
font-size: 1.1rem;
line-height: 1.6;
color: #e0e0e0;
min-height: 60px;
}
.response-text.typing::after {
content: '|';
animation: blink 1s infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.status-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 15px;
padding: 8px 12px;
background: rgba(0,0,0,0.3);
border-radius: 8px;
font-size: 0.8rem;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #444;
}
.status-dot.active { background: #00ff88; animation: pulse 1s infinite; }
.status-dot.processing { background: #ffaa00; animation: pulse 0.3s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.chat-section {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.1);
}
.chat-history {
max-height: 300px;
overflow-y: auto;
margin-bottom: 15px;
}
.message {
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 12px;
max-width: 80%;
font-size: 0.9rem;
}
.message.user {
background: linear-gradient(135deg, #7c3aed, #5b21b6);
margin-left: auto;
}
.message.assistant {
background: rgba(255,255,255,0.1);
}
.controls { display: flex; gap: 10px; }
.record-btn {
flex: 1;
padding: 16px;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.record-btn.idle {
background: linear-gradient(135deg, #00d4ff, #0099cc);
color: white;
}
.record-btn.recording {
background: linear-gradient(135deg, #ff4444, #cc0000);
color: white;
}
.record-btn:disabled {
background: #444;
cursor: not-allowed;
}
.record-btn:hover:not(:disabled) {
transform: translateY(-2px);
}
.clear-btn {
padding: 16px;
border: none;
border-radius: 12px;
background: rgba(255,255,255,0.1);
color: #fff;
cursor: pointer;
}
.timing-info {
margin-top: 10px;
font-size: 0.7rem;
color: #666;
text-align: center;
}
.timing-info span {
margin: 0 5px;
padding: 2px 6px;
background: rgba(255,255,255,0.05);
border-radius: 4px;
}
audio { display: none; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>MuseTalk Avatar</h1>
<p>Converse com o avatar usando sua voz - resposta em ~2s</p>
</header>
<div class="main-content">
<div class="avatar-section">
<div class="avatar-container">
<div class="avatar-image" id="avatar">
<span>🤖</span>
</div>
<div class="response-area">
<div class="response-text" id="response-text">
Ola! Pressione o botao e fale comigo.
</div>
</div>
</div>
<div class="status-bar">
<div class="status-dot" id="status-dot"></div>
<span id="status-text">Pronto</span>
</div>
</div>
<div class="chat-section">
<div class="chat-history" id="chat-history"></div>
<div class="controls">
<button class="record-btn idle" id="record-btn">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
</svg>
<span id="btn-text">Pressione para falar</span>
</button>
<button class="clear-btn" id="clear-btn" title="Limpar">
<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
<div class="timing-info" id="timing-info"></div>
</div>
</div>
</div>
<audio id="audio-player"></audio>
<script>
const recordBtn = document.getElementById('record-btn');
const btnText = document.getElementById('btn-text');
const chatHistory = document.getElementById('chat-history');
const avatar = document.getElementById('avatar');
const responseText = document.getElementById('response-text');
const statusDot = document.getElementById('status-dot');
const statusText = document.getElementById('status-text');
const timingInfo = document.getElementById('timing-info');
const audioPlayer = document.getElementById('audio-player');
const clearBtn = document.getElementById('clear-btn');
let mediaRecorder;
let audioChunks = [];
let isRecording = false;
async function init() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => audioChunks.push(e.data);
mediaRecorder.onstop = () => processAudio();
setStatus('active', 'Pronto para conversar');
} catch (err) {
setStatus('error', 'Erro: permita acesso ao microfone');
recordBtn.disabled = true;
}
}
function setStatus(state, text) {
statusDot.className = 'status-dot ' + state;
statusText.textContent = text;
}
function addMessage(role, text) {
const div = document.createElement('div');
div.className = 'message ' + role;
div.textContent = text;
chatHistory.appendChild(div);
chatHistory.scrollTop = chatHistory.scrollHeight;
}
async function processAudio() {
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
audioChunks = [];
setStatus('processing', 'Transcrevendo...');
responseText.textContent = '...';
responseText.classList.add('typing');
recordBtn.disabled = true;
try {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
const response = await fetch('/api/conversation', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Erro na requisicao');
const result = await response.json();
// Show user message
addMessage('user', result.user_text);
// Show assistant response immediately
setStatus('processing', 'Reproduzindo resposta...');
responseText.classList.remove('typing');
responseText.textContent = result.assistant_text;
addMessage('assistant', result.assistant_text);
// Play audio
avatar.classList.add('speaking');
audioPlayer.src = result.audio_url;
audioPlayer.play();
audioPlayer.onended = () => {
avatar.classList.remove('speaking');
setStatus('active', 'Pronto');
};
// Show timing
const t = result.timing;
timingInfo.innerHTML = `
<span>STT: ${t.stt}s</span>
<span>LLM: ${t.llm}s</span>
<span>TTS: ${t.tts}s</span>
<span><strong>Total: ${t.total}s</strong></span>
<span style="color:#ffaa00">Video: gerando...</span>
`;
// Poll for video status
if (result.job_id) {
pollVideoStatus(result.job_id);
}
} catch (err) {
setStatus('error', 'Erro: ' + err.message);
responseText.classList.remove('typing');
responseText.textContent = 'Desculpe, ocorreu um erro. Tente novamente.';
} finally {
recordBtn.disabled = false;
}
}
function startRecording() {
if (isRecording || !mediaRecorder || recordBtn.disabled) return;
isRecording = true;
audioChunks = [];
mediaRecorder.start();
recordBtn.classList.replace('idle', 'recording');
btnText.textContent = 'Gravando... solte para enviar';
setStatus('processing', 'Gravando...');
}
function stopRecording() {
if (!isRecording) return;
isRecording = false;
mediaRecorder.stop();
recordBtn.classList.replace('recording', 'idle');
btnText.textContent = 'Pressione para falar';
}
recordBtn.addEventListener('mousedown', startRecording);
recordBtn.addEventListener('mouseup', stopRecording);
recordBtn.addEventListener('mouseleave', stopRecording);
recordBtn.addEventListener('touchstart', e => { e.preventDefault(); startRecording(); });
recordBtn.addEventListener('touchend', e => { e.preventDefault(); stopRecording(); });
clearBtn.addEventListener('click', async () => {
await fetch('/api/clear-history', { method: 'POST' });
chatHistory.innerHTML = '';
responseText.textContent = 'Conversa limpa! Fale comigo.';
timingInfo.innerHTML = '';
});
async function pollVideoStatus(jobId) {
const startTime = Date.now();
const poll = async () => {
try {
const response = await fetch(`/api/video-status/${jobId}`);
const data = await response.json();
if (data.status === 'completed') {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
// Update timing to show video is ready
const videoLink = document.createElement('a');
videoLink.href = `/api/video-job/${jobId}`;
videoLink.target = '_blank';
videoLink.style.color = '#00ff88';
videoLink.textContent = `Video: ${elapsed}s ✓`;
// Replace the "gerando" text
const spans = timingInfo.querySelectorAll('span');
const videoSpan = Array.from(spans).find(s => s.textContent.includes('Video:'));
if (videoSpan) {
videoSpan.innerHTML = '';
videoSpan.appendChild(videoLink);
}
} else if (data.status === 'error') {
const spans = timingInfo.querySelectorAll('span');
const videoSpan = Array.from(spans).find(s => s.textContent.includes('Video:'));
if (videoSpan) {
videoSpan.style.color = '#ff4444';
videoSpan.textContent = 'Video: erro';
}
} else {
// Still generating, poll again
setTimeout(poll, 2000);
}
} catch (e) {
console.error('Error polling video status:', e);
}
};
poll();
}
init();
</script>
</body>
</html>