| <!DOCTYPE html> |
| <html lang="pt-BR"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>MuseTalk Fast - Avatar com Vídeo</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, #0f0f1a 0%, #1a1a2e 100%); |
| min-height: 100vh; |
| color: #fff; |
| } |
| .container { max-width: 1000px; margin: 0 auto; padding: 15px; } |
| header { text-align: center; padding: 15px 0; } |
| header h1 { |
| font-size: 1.5rem; |
| background: linear-gradient(90deg, #00d4ff, #7c3aed, #ff6b6b); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| } |
| header p { color: #666; font-size: 0.8rem; margin-top: 5px; } |
| |
| .main-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 20px; |
| } |
| @media (max-width: 768px) { |
| .main-grid { grid-template-columns: 1fr; } |
| } |
| |
| .panel { |
| background: rgba(255,255,255,0.03); |
| border-radius: 16px; |
| padding: 20px; |
| border: 1px solid rgba(255,255,255,0.08); |
| } |
| |
| .video-panel { |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .video-container { |
| position: relative; |
| width: 100%; |
| aspect-ratio: 1; |
| background: #000; |
| border-radius: 12px; |
| overflow: hidden; |
| margin-bottom: 15px; |
| } |
| |
| #avatar-video { |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| } |
| |
| .video-overlay { |
| position: absolute; |
| top: 0; left: 0; right: 0; bottom: 0; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| background: rgba(0,0,0,0.7); |
| transition: opacity 0.3s; |
| } |
| .video-overlay.hidden { opacity: 0; pointer-events: none; } |
| |
| .avatar-placeholder { |
| width: 100px; |
| height: 100px; |
| border-radius: 50%; |
| background: linear-gradient(135deg, #7c3aed, #00d4ff); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 3rem; |
| margin-bottom: 15px; |
| } |
| .avatar-placeholder.speaking { |
| animation: pulse 0.5s infinite; |
| } |
| @keyframes pulse { |
| 0%, 100% { transform: scale(1); } |
| 50% { transform: scale(1.1); } |
| } |
| |
| .progress-bar { |
| width: 80%; |
| height: 6px; |
| background: rgba(255,255,255,0.1); |
| border-radius: 3px; |
| margin-top: 15px; |
| overflow: hidden; |
| } |
| .progress-fill { |
| height: 100%; |
| background: linear-gradient(90deg, #00d4ff, #7c3aed); |
| width: 0%; |
| transition: width 0.3s; |
| } |
| |
| .response-box { |
| background: rgba(0,0,0,0.3); |
| border-radius: 10px; |
| padding: 15px; |
| min-height: 80px; |
| } |
| .response-text { |
| font-size: 1rem; |
| line-height: 1.5; |
| color: #e0e0e0; |
| } |
| |
| .chat-panel { |
| display: flex; |
| flex-direction: column; |
| max-height: 500px; |
| } |
| |
| .chat-history { |
| flex: 1; |
| overflow-y: auto; |
| margin-bottom: 15px; |
| padding-right: 10px; |
| } |
| |
| .message { |
| margin-bottom: 10px; |
| padding: 10px 14px; |
| border-radius: 12px; |
| max-width: 85%; |
| font-size: 0.85rem; |
| animation: slideIn 0.2s ease; |
| } |
| @keyframes slideIn { |
| from { opacity: 0; transform: translateY(10px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .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: 14px; |
| border: none; |
| border-radius: 12px; |
| font-size: 0.95rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: all 0.2s; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 8px; |
| } |
| .record-btn.idle { |
| background: linear-gradient(135deg, #00d4ff, #0099cc); |
| color: white; |
| } |
| .record-btn.recording { |
| background: linear-gradient(135deg, #ff4444, #cc0000); |
| color: white; |
| animation: recording 1s infinite; |
| } |
| @keyframes recording { |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(255,68,68,0.5); } |
| 50% { box-shadow: 0 0 20px 5px rgba(255,68,68,0.3); } |
| } |
| .record-btn:disabled { |
| background: #333; |
| cursor: not-allowed; |
| } |
| |
| .icon-btn { |
| padding: 14px; |
| border: none; |
| border-radius: 12px; |
| background: rgba(255,255,255,0.1); |
| color: #fff; |
| cursor: pointer; |
| transition: all 0.2s; |
| } |
| .icon-btn:hover { background: rgba(255,255,255,0.2); } |
| |
| .status-bar { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 10px 15px; |
| background: rgba(0,0,0,0.3); |
| border-radius: 8px; |
| margin-top: 15px; |
| font-size: 0.75rem; |
| } |
| .status-left { display: flex; align-items: center; gap: 8px; } |
| .status-dot { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| background: #444; |
| } |
| .status-dot.ready { background: #00ff88; } |
| .status-dot.processing { background: #ffaa00; animation: blink 0.5s infinite; } |
| .status-dot.error { background: #ff4444; } |
| @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } |
| |
| .timing-info { |
| color: #666; |
| font-size: 0.7rem; |
| } |
| .timing-info span { |
| margin-left: 10px; |
| padding: 2px 6px; |
| background: rgba(255,255,255,0.05); |
| border-radius: 4px; |
| } |
| |
| .mode-toggle { |
| display: flex; |
| gap: 5px; |
| margin-bottom: 15px; |
| } |
| .mode-btn { |
| flex: 1; |
| padding: 8px; |
| border: none; |
| border-radius: 8px; |
| background: rgba(255,255,255,0.05); |
| color: #888; |
| font-size: 0.8rem; |
| cursor: pointer; |
| transition: all 0.2s; |
| } |
| .mode-btn.active { |
| background: rgba(124,58,237,0.3); |
| color: #fff; |
| } |
| |
| audio { display: none; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <header> |
| <h1>MuseTalk Fast Avatar</h1> |
| <p>Speech-to-Speech com Video Lip-Sync Otimizado</p> |
| </header> |
|
|
| <div class="main-grid"> |
| <div class="panel video-panel"> |
| <div class="mode-toggle"> |
| <button class="mode-btn active" id="mode-audio">Apenas Áudio (~2s)</button> |
| <button class="mode-btn" id="mode-video">Com Vídeo (~30-60s)</button> |
| </div> |
|
|
| <div class="video-container"> |
| <video id="avatar-video" playsinline></video> |
| <div class="video-overlay" id="video-overlay"> |
| <div class="avatar-placeholder" id="avatar-placeholder">🤖</div> |
| <div id="overlay-status">Pronto para conversar</div> |
| <div class="progress-bar" id="progress-bar" style="display:none;"> |
| <div class="progress-fill" id="progress-fill"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="response-box"> |
| <div class="response-text" id="response-text"> |
| Olá! Pressione o botão e fale comigo. Escolha o modo acima. |
| </div> |
| </div> |
| </div> |
|
|
| <div class="panel chat-panel"> |
| <div class="chat-history" id="chat-history"></div> |
|
|
| <div class="controls"> |
| <button class="record-btn idle" id="record-btn"> |
| <svg width="20" height="20" 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">Segure para falar</span> |
| </button> |
| <button class="icon-btn" id="clear-btn" title="Limpar conversa"> |
| <svg width="20" height="20" 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="status-bar"> |
| <div class="status-left"> |
| <div class="status-dot ready" id="status-dot"></div> |
| <span id="status-text">Pronto</span> |
| </div> |
| <div class="timing-info" id="timing-info"></div> |
| </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 avatarVideo = document.getElementById('avatar-video'); |
| const videoOverlay = document.getElementById('video-overlay'); |
| const avatarPlaceholder = document.getElementById('avatar-placeholder'); |
| const overlayStatus = document.getElementById('overlay-status'); |
| const progressBar = document.getElementById('progress-bar'); |
| const progressFill = document.getElementById('progress-fill'); |
| 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'); |
| const modeAudio = document.getElementById('mode-audio'); |
| const modeVideo = document.getElementById('mode-video'); |
| |
| let mediaRecorder; |
| let audioChunks = []; |
| let isRecording = false; |
| let videoMode = false; |
| |
| |
| modeAudio.onclick = () => { |
| videoMode = false; |
| modeAudio.classList.add('active'); |
| modeVideo.classList.remove('active'); |
| }; |
| modeVideo.onclick = () => { |
| videoMode = true; |
| modeVideo.classList.add('active'); |
| modeAudio.classList.remove('active'); |
| }; |
| |
| 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('ready', 'Pronto'); |
| } catch (err) { |
| setStatus('error', 'Erro: permita 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; |
| } |
| |
| function showProgress(percent, message) { |
| progressBar.style.display = 'block'; |
| progressFill.style.width = percent + '%'; |
| overlayStatus.textContent = message || `Gerando vídeo: ${percent}%`; |
| } |
| |
| function hideProgress() { |
| progressBar.style.display = 'none'; |
| progressFill.style.width = '0%'; |
| } |
| |
| async function processAudio() { |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
| audioChunks = []; |
| |
| recordBtn.disabled = true; |
| setStatus('processing', 'Processando...'); |
| responseText.textContent = '...'; |
| |
| const formData = new FormData(); |
| formData.append('audio', audioBlob, 'recording.webm'); |
| |
| try { |
| if (videoMode) { |
| await processWithVideo(formData); |
| } else { |
| await processAudioOnly(formData); |
| } |
| } catch (err) { |
| setStatus('error', 'Erro: ' + err.message); |
| responseText.textContent = 'Erro ao processar. Tente novamente.'; |
| hideProgress(); |
| } finally { |
| recordBtn.disabled = false; |
| } |
| } |
| |
| async function processAudioOnly(formData) { |
| overlayStatus.textContent = 'Processando...'; |
| |
| const response = await fetch('/api/conversation', { |
| method: 'POST', |
| body: formData |
| }); |
| |
| if (!response.ok) throw new Error('Erro na requisição'); |
| const result = await response.json(); |
| |
| |
| addMessage('user', result.user_text); |
| addMessage('assistant', result.assistant_text); |
| responseText.textContent = result.assistant_text; |
| |
| |
| overlayStatus.textContent = 'Reproduzindo...'; |
| avatarPlaceholder.classList.add('speaking'); |
| audioPlayer.src = result.audio_url; |
| await audioPlayer.play(); |
| |
| audioPlayer.onended = () => { |
| avatarPlaceholder.classList.remove('speaking'); |
| overlayStatus.textContent = 'Pronto'; |
| setStatus('ready', 'Pronto'); |
| }; |
| |
| |
| 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>Total: ${t.total}s</span>`; |
| setStatus('ready', 'Reproduzindo áudio'); |
| } |
| |
| async function processWithVideo(formData) { |
| showProgress(5, 'Enviando áudio...'); |
| |
| const response = await fetch('/api/conversation', { |
| method: 'POST', |
| body: formData |
| }); |
| |
| if (!response.ok) throw new Error('Erro na requisição'); |
| const result = await response.json(); |
| |
| |
| addMessage('user', result.user_text); |
| addMessage('assistant', result.assistant_text); |
| responseText.textContent = result.assistant_text; |
| |
| const t = result.timing; |
| timingInfo.innerHTML = `<span>STT: ${t.stt}s</span><span>LLM: ${t.llm}s</span><span>TTS: ${t.tts}s</span>`; |
| |
| |
| avatarPlaceholder.classList.add('speaking'); |
| audioPlayer.src = result.audio_url; |
| |
| |
| showProgress(10, 'Gerando vídeo...'); |
| const jobId = result.job_id; |
| |
| const pollVideo = async () => { |
| try { |
| const statusRes = await fetch(`/api/video-status/${jobId}`); |
| const status = await statusRes.json(); |
| |
| if (status.status === 'completed') { |
| |
| showProgress(100, 'Vídeo pronto!'); |
| setTimeout(() => { |
| videoOverlay.classList.add('hidden'); |
| avatarVideo.src = `/api/video/${jobId}`; |
| avatarVideo.play(); |
| hideProgress(); |
| setStatus('ready', 'Reproduzindo vídeo'); |
| |
| avatarVideo.onended = () => { |
| videoOverlay.classList.remove('hidden'); |
| overlayStatus.textContent = 'Pronto'; |
| avatarPlaceholder.classList.remove('speaking'); |
| }; |
| |
| |
| timingInfo.innerHTML += `<span>Vídeo: pronto</span>`; |
| }, 500); |
| } else if (status.status === 'error') { |
| throw new Error(status.error || 'Erro ao gerar vídeo'); |
| } else { |
| showProgress(status.progress || 10, status.message || 'Gerando...'); |
| setTimeout(pollVideo, 1000); |
| } |
| } catch (e) { |
| console.error('Poll error:', e); |
| hideProgress(); |
| overlayStatus.textContent = 'Erro no vídeo, usando áudio'; |
| } |
| }; |
| |
| |
| audioPlayer.play(); |
| audioPlayer.onended = () => { |
| if (!avatarVideo.src) { |
| avatarPlaceholder.classList.remove('speaking'); |
| } |
| }; |
| |
| pollVideo(); |
| } |
| |
| function startRecording() { |
| if (isRecording || !mediaRecorder || recordBtn.disabled) return; |
| isRecording = true; |
| audioChunks = []; |
| mediaRecorder.start(); |
| recordBtn.classList.replace('idle', 'recording'); |
| btnText.textContent = 'Solte para enviar'; |
| setStatus('processing', 'Gravando...'); |
| overlayStatus.textContent = 'Gravando...'; |
| } |
| |
| function stopRecording() { |
| if (!isRecording) return; |
| isRecording = false; |
| mediaRecorder.stop(); |
| recordBtn.classList.replace('recording', 'idle'); |
| btnText.textContent = 'Segure 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!'; |
| timingInfo.innerHTML = ''; |
| avatarVideo.src = ''; |
| videoOverlay.classList.remove('hidden'); |
| overlayStatus.textContent = 'Pronto'; |
| }); |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|