Diffusers
MuseTalk1.5 / server /static /index_fast.html
Marcos
Add speech-to-speech avatar server with real-time video generation
c636b2c
Raw
History Blame Contribute Delete
20.2 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 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>
// Elements
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; // false = audio only, true = with video
// Mode toggle
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();
// Show messages
addMessage('user', result.user_text);
addMessage('assistant', result.assistant_text);
responseText.textContent = result.assistant_text;
// Play audio
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');
};
// 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>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();
// Show messages immediately
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>`;
// Start playing audio while video generates
avatarPlaceholder.classList.add('speaking');
audioPlayer.src = result.audio_url;
// Poll for video status
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') {
// Video ready - play it
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');
};
// Update timing
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';
}
};
// Play audio immediately, poll video in background
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>