| <!DOCTYPE html> |
| <html lang="pt-BR"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>MuseTalk - WebSocket Streaming</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; |
| display: flex; |
| justify-content: center; |
| align-items: center; |
| color: white; |
| } |
| .app-container { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 20px; |
| padding: 20px; |
| } |
| .avatar-section { |
| position: relative; |
| width: 400px; |
| height: 400px; |
| border-radius: 24px; |
| overflow: hidden; |
| background: #0f0f1a; |
| box-shadow: 0 20px 40px rgba(0,0,0,0.5); |
| } |
| #idle-video, #ws-canvas { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| border-radius: 24px; |
| } |
| #idle-video { z-index: 5; } |
| #ws-canvas { |
| z-index: 10; |
| opacity: 0; |
| transition: opacity 0.2s; |
| } |
| #ws-canvas.active { opacity: 1; } |
| .status-bar { |
| position: absolute; |
| top: 12px; |
| left: 12px; |
| display: flex; |
| gap: 12px; |
| z-index: 20; |
| } |
| .status-dot { |
| width: 10px; |
| height: 10px; |
| border-radius: 50%; |
| background: #666; |
| display: inline-block; |
| margin-right: 4px; |
| } |
| .status-dot.connected { background: #4ade80; } |
| .status-dot.streaming { background: #60a5fa; animation: pulse 1s infinite; } |
| .status-dot.error { background: #f87171; } |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.5; } |
| } |
| .controls { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 16px; |
| } |
| #record-btn { |
| width: 80px; |
| height: 80px; |
| border-radius: 50%; |
| border: none; |
| background: linear-gradient(145deg, #ef4444, #dc2626); |
| color: white; |
| font-size: 14px; |
| font-weight: 600; |
| cursor: pointer; |
| box-shadow: 0 8px 20px rgba(239, 68, 68, 0.4); |
| transition: all 0.2s; |
| } |
| #record-btn:hover { transform: scale(1.05); } |
| #record-btn:active, #record-btn.recording { |
| background: linear-gradient(145deg, #dc2626, #b91c1c); |
| transform: scale(0.95); |
| } |
| #record-btn:disabled { |
| background: #666; |
| cursor: not-allowed; |
| box-shadow: none; |
| } |
| .response-text { |
| text-align: center; |
| font-size: 16px; |
| color: #94a3b8; |
| max-width: 400px; |
| min-height: 48px; |
| } |
| .stats { |
| display: flex; |
| gap: 24px; |
| font-size: 14px; |
| color: #64748b; |
| } |
| .stats span { font-weight: 600; color: #94a3b8; } |
| .mode-badge { |
| background: #22c55e; |
| color: white; |
| padding: 4px 12px; |
| border-radius: 12px; |
| font-size: 12px; |
| font-weight: 600; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <div class="mode-badge">WebSocket Mode (Proxy-friendly)</div> |
|
|
| <div class="avatar-section"> |
| <video id="idle-video" autoplay loop muted playsinline preload="auto" src="avatar_videos/idle.mp4"></video> |
| <canvas id="ws-canvas"></canvas> |
|
|
| <div class="status-bar"> |
| <div><span class="status-dot" id="ws-status"></span>WS</div> |
| </div> |
| </div> |
|
|
| <div class="controls"> |
| <button id="record-btn" disabled>Conectando...</button> |
| <p class="response-text" id="response-text">Iniciando...</p> |
| </div> |
|
|
| <div class="stats"> |
| <div>Latência: <span id="latency">-</span></div> |
| <div>Frames: <span id="frame-count">0</span></div> |
| <div>FPS: <span id="fps">-</span></div> |
| </div> |
| </div> |
|
|
| <audio id="audio-player" preload="none"></audio> |
|
|
| <script> |
| |
| const idleVideo = document.getElementById('idle-video'); |
| const wsCanvas = document.getElementById('ws-canvas'); |
| const ctx = wsCanvas.getContext('2d'); |
| const recordBtn = document.getElementById('record-btn'); |
| const responseText = document.getElementById('response-text'); |
| const wsStatus = document.getElementById('ws-status'); |
| const latencyEl = document.getElementById('latency'); |
| const frameCountEl = document.getElementById('frame-count'); |
| const fpsEl = document.getElementById('fps'); |
| const audioPlayer = document.getElementById('audio-player'); |
| |
| |
| let ws = null; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| let frameCount = 0; |
| let startTime = 0; |
| let frameImage = new Image(); |
| |
| |
| const urlParams = new URLSearchParams(window.location.search); |
| const isDemoMode = urlParams.get('demo') === 'true'; |
| let demoMetrics = {}; |
| |
| |
| function connect() { |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const wsPath = window.location.pathname.replace(/\/[^\/]*$/, '') + '/ws/stream'; |
| const wsUrl = `${protocol}//${window.location.host}${wsPath}`; |
| |
| console.log('[WS] Connecting to:', wsUrl); |
| responseText.textContent = 'Conectando...'; |
| |
| ws = new WebSocket(wsUrl); |
| |
| ws.onopen = () => { |
| console.log('[WS] Connected'); |
| wsStatus.className = 'status-dot connected'; |
| recordBtn.disabled = false; |
| recordBtn.textContent = 'Falar'; |
| responseText.textContent = 'Pressione e fale'; |
| |
| |
| ws.send(JSON.stringify({ type: 'config', ws_video: true })); |
| }; |
| |
| ws.onclose = () => { |
| console.log('[WS] Disconnected'); |
| wsStatus.className = 'status-dot error'; |
| recordBtn.disabled = true; |
| responseText.textContent = 'Desconectado - recarregue'; |
| setTimeout(connect, 3000); |
| }; |
| |
| ws.onerror = (e) => { |
| console.error('[WS] Error:', e); |
| wsStatus.className = 'status-dot error'; |
| }; |
| |
| ws.onmessage = (event) => { |
| const data = JSON.parse(event.data); |
| handleMessage(data); |
| }; |
| |
| |
| setInterval(() => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ type: 'ping' })); |
| } |
| }, 10000); |
| } |
| |
| function handleMessage(data) { |
| switch (data.type) { |
| case 'status': |
| responseText.textContent = data.message; |
| break; |
| |
| case 'transcription': |
| responseText.textContent = `"${data.text}"`; |
| break; |
| |
| case 'response': |
| responseText.textContent = data.text; |
| break; |
| |
| case 'audio': |
| const basePath = window.location.pathname.replace(/\/[^\/]*$/, ''); |
| audioPlayer.src = basePath + data.url; |
| audioPlayer.load(); |
| break; |
| |
| case 'info': |
| frameCount = 0; |
| startTime = Date.now(); |
| frameCountEl.textContent = `0/${data.total_frames}`; |
| wsCanvas.classList.add('active'); |
| audioPlayer.play().catch(e => console.log('[Audio] Autoplay blocked')); |
| |
| if (isDemoMode) { |
| demoMetrics.firstFrameInfoTime = Date.now(); |
| demoMetrics.expectedFrames = data.total_frames; |
| } |
| break; |
| |
| case 'frame': |
| case 'video_frame': |
| frameImage.onload = function() { |
| if (wsCanvas.width !== frameImage.width) { |
| wsCanvas.width = frameImage.width; |
| wsCanvas.height = frameImage.height; |
| } |
| ctx.drawImage(frameImage, 0, 0); |
| }; |
| frameImage.src = 'data:image/jpeg;base64,' + data.frame; |
| |
| frameCount++; |
| frameCountEl.textContent = `${frameCount}/${data.total || '?'}`; |
| |
| |
| const elapsed = (Date.now() - startTime) / 1000; |
| if (elapsed > 0) { |
| fpsEl.textContent = (frameCount / elapsed).toFixed(1); |
| } |
| |
| if (isDemoMode && frameCount === 1) { |
| demoMetrics.firstVideoFrameTime = Date.now(); |
| console.log('[DEMO] First frame at', demoMetrics.firstVideoFrameTime - demoMetrics.audioSentTime, 'ms'); |
| } |
| break; |
| |
| case 'done': |
| const totalTime = Date.now() - startTime; |
| latencyEl.textContent = `${totalTime}ms`; |
| frameCountEl.textContent = data.total_frames; |
| |
| setTimeout(() => { |
| wsCanvas.classList.remove('active'); |
| }, 1000); |
| |
| if (isDemoMode) { |
| demoMetrics.responseCompleteTime = Date.now(); |
| demoMetrics.totalFrames = data.total_frames; |
| demoMetrics.serverMetrics = data.metrics; |
| exportDemoMetrics({ success: true }); |
| } |
| break; |
| |
| case 'pong': |
| break; |
| |
| case 'error': |
| console.error('[WS] Server error:', data.message); |
| responseText.textContent = 'Erro: ' + data.message; |
| break; |
| } |
| } |
| |
| |
| async function startRecording() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }); |
| audioChunks = []; |
| |
| mediaRecorder.ondataavailable = (e) => { |
| if (e.data.size > 0) audioChunks.push(e.data); |
| }; |
| |
| mediaRecorder.onstop = async () => { |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
| const reader = new FileReader(); |
| reader.onloadend = () => { |
| const base64 = reader.result.split(',')[1]; |
| |
| if (isDemoMode) { |
| demoMetrics.audioSentTime = Date.now(); |
| } |
| |
| ws.send(JSON.stringify({ |
| type: 'start', |
| audio_base64: base64, |
| ws_video: true, |
| resolution: 256, |
| batch_size: 16 |
| })); |
| |
| responseText.textContent = 'Processando...'; |
| wsStatus.className = 'status-dot streaming'; |
| }; |
| reader.readAsDataURL(audioBlob); |
| stream.getTracks().forEach(t => t.stop()); |
| }; |
| |
| mediaRecorder.start(); |
| isRecording = true; |
| recordBtn.classList.add('recording'); |
| recordBtn.textContent = 'Soltar'; |
| responseText.textContent = 'Gravando...'; |
| } catch (e) { |
| console.error('[Mic] Error:', e); |
| responseText.textContent = 'Erro: permita acesso ao microfone'; |
| } |
| } |
| |
| function stopRecording() { |
| if (mediaRecorder && isRecording) { |
| mediaRecorder.stop(); |
| isRecording = false; |
| recordBtn.classList.remove('recording'); |
| recordBtn.textContent = 'Falar'; |
| } |
| } |
| |
| |
| async function runDemoMode() { |
| console.log('[DEMO] Starting demo mode...'); |
| demoMetrics = { pageLoadTime: Date.now() }; |
| |
| await new Promise(r => setTimeout(r, 2000)); |
| |
| try { |
| const basePath = window.location.pathname.replace(/\/[^\/]*$/, ''); |
| const response = await fetch(basePath + '/static/demo_audio.webm'); |
| const audioBlob = await response.blob(); |
| |
| const reader = new FileReader(); |
| reader.onloadend = () => { |
| const base64 = reader.result.split(',')[1]; |
| demoMetrics.audioSentTime = Date.now(); |
| |
| ws.send(JSON.stringify({ |
| type: 'start', |
| audio_base64: base64, |
| ws_video: true, |
| resolution: 256, |
| batch_size: 16 |
| })); |
| |
| responseText.textContent = 'Demo: processando...'; |
| wsStatus.className = 'status-dot streaming'; |
| }; |
| reader.readAsDataURL(audioBlob); |
| } catch (e) { |
| console.error('[DEMO] Error:', e); |
| exportDemoMetrics({ success: false, error: e.message }); |
| } |
| } |
| |
| function exportDemoMetrics(result) { |
| const metrics = { ...demoMetrics, ...result }; |
| console.log('[DEMO] Final metrics:', JSON.stringify(metrics)); |
| window.DEMO_METRICS = metrics; |
| window.DEMO_COMPLETE = true; |
| } |
| |
| |
| recordBtn.addEventListener('mousedown', startRecording); |
| recordBtn.addEventListener('mouseup', stopRecording); |
| recordBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); }); |
| recordBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); }); |
| recordBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); }); |
| |
| |
| connect(); |
| |
| if (isDemoMode) { |
| setTimeout(runDemoMode, 1000); |
| } |
| </script> |
| </body> |
| </html> |
|
|