Instructions to use marcosremar2/MuseTalk1.5 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use marcosremar2/MuseTalk1.5 with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("marcosremar2/MuseTalk1.5", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
| <html lang="pt-BR"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>AI Avatar - WebRTC</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { | |
| font-family: 'Segoe UI', sans-serif; | |
| background: #0a0a0f; | |
| min-height: 100vh; | |
| color: #fff; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .app-container { | |
| width: 100%; | |
| max-width: 420px; | |
| padding: 20px; | |
| } | |
| .avatar-section { | |
| position: relative; | |
| width: 100%; | |
| aspect-ratio: 9/16; | |
| max-height: 70vh; | |
| border-radius: 24px; | |
| overflow: hidden; | |
| background: linear-gradient(145deg, #1a1a2e, #0f0f1a); | |
| box-shadow: 0 20px 40px rgba(0,0,0,0.5); | |
| } | |
| #rtc-video, #idle-video, #ws-video { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| border-radius: 24px; | |
| } | |
| #idle-video { | |
| z-index: 5; | |
| } | |
| #rtc-video { | |
| z-index: 10; | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| } | |
| #rtc-video.active { | |
| opacity: 1; | |
| } | |
| .status-bar { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| right: 10px; | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 11px; | |
| color: #aaa; | |
| z-index: 20; | |
| } | |
| .status-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: #444; | |
| margin-right: 5px; | |
| display: inline-block; | |
| } | |
| .status-dot.connected { background: #4CAF50; } | |
| .status-dot.streaming { background: #2196F3; animation: pulse 1s infinite; } | |
| .status-dot.error { background: #f44336; } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .response-overlay { | |
| position: absolute; | |
| bottom: 80px; | |
| left: 10px; | |
| right: 10px; | |
| text-align: center; | |
| z-index: 15; | |
| } | |
| .response-text { | |
| background: rgba(0,0,0,0.7); | |
| padding: 10px 15px; | |
| border-radius: 12px; | |
| font-size: 14px; | |
| display: inline-block; | |
| max-width: 90%; | |
| } | |
| .controls { | |
| display: flex; | |
| justify-content: center; | |
| gap: 15px; | |
| margin-top: 20px; | |
| } | |
| .record-btn { | |
| width: 70px; | |
| height: 70px; | |
| border-radius: 50%; | |
| border: none; | |
| background: linear-gradient(145deg, #667eea, #764ba2); | |
| color: white; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: transform 0.2s, box-shadow 0.2s; | |
| } | |
| .record-btn:hover { | |
| transform: scale(1.05); | |
| box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); | |
| } | |
| .record-btn.recording { | |
| background: linear-gradient(145deg, #f44336, #e91e63); | |
| animation: pulse 0.5s infinite; | |
| } | |
| .record-btn svg { | |
| width: 28px; | |
| height: 28px; | |
| } | |
| .record-btn:disabled { | |
| background: #444; | |
| cursor: not-allowed; | |
| opacity: 0.5; | |
| } | |
| .stats-bar { | |
| display: flex; | |
| justify-content: center; | |
| gap: 20px; | |
| margin-top: 15px; | |
| font-size: 12px; | |
| color: #888; | |
| } | |
| .metrics-panel { | |
| display: none; | |
| position: fixed; | |
| bottom: 100px; | |
| left: 10px; | |
| right: 10px; | |
| background: rgba(0,0,0,0.9); | |
| padding: 15px; | |
| border-radius: 12px; | |
| font-size: 11px; | |
| max-height: 200px; | |
| overflow: auto; | |
| z-index: 1000; | |
| } | |
| .metrics-panel.visible { display: block; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app-container"> | |
| <div class="avatar-section"> | |
| <video id="idle-video" autoplay loop muted playsinline preload="auto" src="avatar_videos/idle.mp4"></video> | |
| <video id="rtc-video" autoplay playsinline></video> | |
| <canvas id="ws-video" style="display:none;"></canvas> | |
| <div class="status-bar"> | |
| <div><span class="status-dot" id="ws-status"></span><span id="ws-text">WS</span></div> | |
| <div><span class="status-dot" id="rtc-status"></span><span id="rtc-text">RTC</span></div> | |
| <div id="stats-text">-</div> | |
| </div> | |
| <div class="response-overlay"> | |
| <div class="response-text" id="response-text">Conectando...</div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button class="record-btn" id="record-btn" disabled> | |
| <svg viewBox="0 0 24 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> | |
| </button> | |
| </div> | |
| <div class="stats-bar"> | |
| <span>Frames: <b id="frame-count">0</b></span> | |
| <span>Latency: <b id="latency">-</b></span> | |
| <span onclick="toggleMetrics()" style="cursor:pointer">π</span> | |
| </div> | |
| <div class="metrics-panel" id="metrics-panel"> | |
| <div style="display:flex; justify-content:space-between; margin-bottom:10px;"> | |
| <b>Metrics</b> | |
| <span onclick="toggleMetrics()" style="cursor:pointer">β</span> | |
| </div> | |
| <div id="metrics-content">Waiting...</div> | |
| </div> | |
| </div> | |
| <audio id="audio-player" preload="none"></audio> | |
| <script> | |
| // Elements | |
| const idleVideo = document.getElementById('idle-video'); | |
| const rtcVideo = document.getElementById('rtc-video'); | |
| const wsVideo = document.getElementById('ws-video'); | |
| const wsVideoCtx = wsVideo.getContext('2d'); | |
| const recordBtn = document.getElementById('record-btn'); | |
| // WebSocket video fallback state | |
| let useWsVideo = false; | |
| let wsVideoImage = new Image(); | |
| const responseText = document.getElementById('response-text'); | |
| const wsStatus = document.getElementById('ws-status'); | |
| const rtcStatus = document.getElementById('rtc-status'); | |
| const statsText = document.getElementById('stats-text'); | |
| const frameCountEl = document.getElementById('frame-count'); | |
| const latencyEl = document.getElementById('latency'); | |
| const audioPlayer = document.getElementById('audio-player'); | |
| const metricsPanel = document.getElementById('metrics-panel'); | |
| const metricsContent = document.getElementById('metrics-content'); | |
| // State | |
| let ws = null; | |
| let pc = null; // RTCPeerConnection | |
| let isRecording = false; | |
| let mediaRecorder = null; | |
| let audioChunks = []; | |
| let frameCount = 0; | |
| let startTime = 0; | |
| let sessionLogs = []; | |
| // URL parameters | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const isDemoMode = urlParams.get('demo') === 'true'; | |
| const forceRelay = urlParams.get('relay') === 'true'; // Force TURN relay (for proxy) | |
| const demoMetrics = { | |
| pageLoadTime: Date.now(), | |
| wsConnectTime: null, | |
| rtcConnectTime: null, | |
| // Latency metrics (from audio sent) | |
| audioSentTime: null, | |
| firstResponseTime: null, // First server response (status/transcription) | |
| transcriptionTime: null, // When transcription arrived | |
| llmResponseTime: null, // When LLM response arrived | |
| audioUrlTime: null, // When audio URL arrived | |
| firstFrameInfoTime: null, // When frame info arrived (total frames) | |
| firstVideoFrameTime: null, // When first actual video frame rendered | |
| responseCompleteTime: null, | |
| // Quality metrics | |
| totalFrames: 0, | |
| expectedFrames: 0, | |
| droppedFrames: 0, | |
| frameTimings: [], // Track each frame arrival for stutter detection | |
| stutterCount: 0, // Number of stutters detected | |
| maxFrameGap: 0, // Longest gap between frames (ms) | |
| avgFrameGap: 0, // Average gap between frames | |
| // Audio sync | |
| audioStartTime: null, // When audio started playing | |
| videoStartTime: null, // When video started playing | |
| audioVideoSyncOffset: null, // Difference between audio and video start | |
| // Server metrics | |
| serverMetrics: null | |
| }; | |
| // Initialize | |
| async function init() { | |
| connectWebSocket(); | |
| await setupWebRTC(); | |
| } | |
| // WebSocket for signaling | |
| function connectWebSocket() { | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const basePath = window.location.pathname.replace(/\/$/, '').replace('/rtc', ''); | |
| const wsUrl = `${protocol}//${window.location.host}${basePath}/ws/rtc`; | |
| console.log('[WS] Connecting to:', wsUrl); | |
| ws = new WebSocket(wsUrl); | |
| ws.onopen = async () => { | |
| console.log('[WS] Connected'); | |
| wsStatus.className = 'status-dot connected'; | |
| // Demo: record WS connect time | |
| if (isDemoMode) { | |
| demoMetrics.wsConnectTime = Date.now(); | |
| console.log('[DEMO] WS connected in', demoMetrics.wsConnectTime - demoMetrics.pageLoadTime, 'ms'); | |
| } | |
| // Wait a bit for pc to be ready, then send offer | |
| await new Promise(r => setTimeout(r, 100)); | |
| console.log('[WS] Sending WebRTC offer...'); | |
| sendOffer(); | |
| }; | |
| ws.onclose = () => { | |
| console.log('[WS] Disconnected'); | |
| wsStatus.className = 'status-dot error'; | |
| setTimeout(connectWebSocket, 3000); | |
| }; | |
| ws.onerror = (e) => console.error('[WS] Error:', e); | |
| ws.onmessage = async (event) => { | |
| const data = JSON.parse(event.data); | |
| await handleMessage(data); | |
| }; | |
| // Keepalive | |
| setInterval(() => { | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({type: 'ping'})); | |
| } | |
| }, 10000); | |
| } | |
| // WebRTC setup | |
| async function setupWebRTC() { | |
| pc = new RTCPeerConnection({ | |
| iceServers: [ | |
| // STUN servers (for direct connections) | |
| { urls: 'stun:stun.l.google.com:19302' }, | |
| { urls: 'stun:stun1.l.google.com:19302' }, | |
| { urls: 'stun:stun.services.mozilla.com' }, | |
| // OpenRelay TURN servers (free, for proxied connections) | |
| // UDP (fastest if not blocked) | |
| { | |
| urls: 'turn:openrelay.metered.ca:80', | |
| username: 'openrelayproject', | |
| credential: 'openrelayproject' | |
| }, | |
| // TCP on port 443 (works through most firewalls) | |
| { | |
| urls: 'turn:openrelay.metered.ca:443?transport=tcp', | |
| username: 'openrelayproject', | |
| credential: 'openrelayproject' | |
| }, | |
| // TURNS - TLS encrypted (works through strict proxies) | |
| { | |
| urls: 'turns:openrelay.metered.ca:443?transport=tcp', | |
| username: 'openrelayproject', | |
| credential: 'openrelayproject' | |
| }, | |
| // Alternative TURN servers | |
| { | |
| urls: 'turn:relay.metered.ca:80', | |
| username: 'e7b2d4b8a1c3f5e9d7a2', | |
| credential: 'hK9mNpQrStUvWxYz' | |
| }, | |
| { | |
| urls: 'turns:relay.metered.ca:443?transport=tcp', | |
| username: 'e7b2d4b8a1c3f5e9d7a2', | |
| credential: 'hK9mNpQrStUvWxYz' | |
| } | |
| ], | |
| iceCandidatePoolSize: 10, | |
| iceTransportPolicy: forceRelay ? 'relay' : 'all' // 'relay' forces TURN only | |
| }); | |
| if (forceRelay) { | |
| console.log('[RTC] RELAY MODE: Forcing TURN servers only'); | |
| } | |
| pc.ontrack = (event) => { | |
| console.log('[RTC] Got track:', event.track.kind, event.streams); | |
| if (event.track.kind === 'video') { | |
| console.log('[RTC] Setting video srcObject'); | |
| rtcVideo.srcObject = event.streams[0]; | |
| rtcVideo.play().catch(e => console.log('[RTC] Video play error:', e)); | |
| rtcStatus.className = 'status-dot streaming'; | |
| // Demo: track first video frame rendered | |
| if (isDemoMode) { | |
| let lastFrameTime = null; | |
| let frameIndex = 0; | |
| // Use requestVideoFrameCallback if available (Chrome 83+) | |
| if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { | |
| const trackFrame = (now, metadata) => { | |
| const currentTime = Date.now(); | |
| if (frameIndex === 0 && demoMetrics.audioSentTime) { | |
| demoMetrics.firstVideoFrameTime = currentTime; | |
| console.log('[DEMO] First VIDEO FRAME rendered at', currentTime - demoMetrics.audioSentTime, 'ms'); | |
| } | |
| // Track frame timing for stutter detection | |
| if (lastFrameTime) { | |
| const gap = currentTime - lastFrameTime; | |
| demoMetrics.frameTimings.push(gap); | |
| // Detect stutter (gap > 100ms = stutter for 30fps video) | |
| if (gap > 100) { | |
| demoMetrics.stutterCount++; | |
| console.log('[DEMO] Stutter detected! Frame', frameIndex, 'gap:', gap, 'ms'); | |
| } | |
| if (gap > demoMetrics.maxFrameGap) { | |
| demoMetrics.maxFrameGap = gap; | |
| } | |
| } | |
| lastFrameTime = currentTime; | |
| frameIndex++; | |
| demoMetrics.totalFrames = frameIndex; | |
| // Continue tracking | |
| rtcVideo.requestVideoFrameCallback(trackFrame); | |
| }; | |
| rtcVideo.requestVideoFrameCallback(trackFrame); | |
| } else { | |
| // Fallback: use loadeddata event | |
| rtcVideo.onloadeddata = () => { | |
| if (demoMetrics.audioSentTime && !demoMetrics.firstVideoFrameTime) { | |
| demoMetrics.firstVideoFrameTime = Date.now(); | |
| console.log('[DEMO] First video data at', demoMetrics.firstVideoFrameTime - demoMetrics.audioSentTime, 'ms'); | |
| } | |
| }; | |
| } | |
| } | |
| } | |
| }; | |
| pc.oniceconnectionstatechange = () => { | |
| console.log('[RTC] ICE state:', pc.iceConnectionState); | |
| }; | |
| pc.onicecandidate = (event) => { | |
| // ICE candidates handled automatically with trickle ICE | |
| }; | |
| pc.onconnectionstatechange = () => { | |
| console.log('[RTC] Connection state:', pc.connectionState); | |
| if (pc.connectionState === 'connected') { | |
| rtcStatus.className = 'status-dot connected'; | |
| recordBtn.disabled = false; | |
| responseText.textContent = 'WebRTC conectado - Pressione e fale'; | |
| } else if (pc.connectionState === 'failed') { | |
| rtcStatus.className = 'status-dot error'; | |
| responseText.textContent = 'WebRTC falhou - recarregue a pΓ‘gina'; | |
| } else if (pc.connectionState === 'connecting') { | |
| responseText.textContent = 'Conectando WebRTC...'; | |
| } | |
| }; | |
| // Add transceiver for receiving video | |
| pc.addTransceiver('video', { direction: 'recvonly' }); | |
| } | |
| async function sendOffer() { | |
| console.log('[RTC] sendOffer called, pc:', !!pc, 'ws:', !!ws, 'ws.readyState:', ws?.readyState); | |
| if (!pc) { console.error('[RTC] No peer connection!'); return; } | |
| if (!ws || ws.readyState !== WebSocket.OPEN) { console.error('[RTC] WebSocket not open!'); return; } | |
| try { | |
| console.log('[RTC] Creating offer...'); | |
| const offer = await pc.createOffer(); | |
| console.log('[RTC] Setting local description...'); | |
| await pc.setLocalDescription(offer); | |
| // Use trickle ICE: wait a short time for initial candidates, then send | |
| // This works better through proxies/restrictive NATs | |
| console.log('[RTC] Gathering ICE candidates (trickle mode)...'); | |
| let candidateCount = 0; | |
| const gatheringPromise = new Promise(resolve => { | |
| // Track candidates as they're gathered | |
| pc.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| candidateCount++; | |
| console.log('[RTC] ICE candidate', candidateCount, ':', event.candidate.type, event.candidate.protocol); | |
| } else { | |
| // null candidate means gathering complete | |
| console.log('[RTC] ICE gathering complete, total candidates:', candidateCount); | |
| resolve(); | |
| } | |
| }; | |
| // Also resolve if state changes to complete | |
| pc.onicegatheringstatechange = () => { | |
| console.log('[RTC] ICE gathering state:', pc.iceGatheringState); | |
| if (pc.iceGatheringState === 'complete') resolve(); | |
| }; | |
| }); | |
| // Wait up to 5 seconds for some candidates, but don't block forever | |
| // TURN candidates can take a while, so we give them some time | |
| await Promise.race([ | |
| gatheringPromise, | |
| new Promise(resolve => setTimeout(() => { | |
| console.log('[RTC] Timeout waiting for ICE, sending with', candidateCount, 'candidates'); | |
| resolve(); | |
| }, 5000)) | |
| ]); | |
| // Send offer with whatever candidates we have | |
| console.log('[RTC] Sending offer with', candidateCount, 'candidates...'); | |
| ws.send(JSON.stringify({ | |
| type: 'offer', | |
| sdp: pc.localDescription.sdp | |
| })); | |
| console.log('[RTC] Sent offer successfully'); | |
| } catch (e) { | |
| console.error('[RTC] Offer error:', e); | |
| } | |
| } | |
| async function handleMessage(data) { | |
| switch (data.type) { | |
| case 'answer': | |
| console.log('[RTC] Got answer, setting remote description...'); | |
| await pc.setRemoteDescription(new RTCSessionDescription({ | |
| type: 'answer', | |
| sdp: data.sdp | |
| })); | |
| console.log('[RTC] Remote description set, waiting for connection...'); | |
| break; | |
| case 'status': | |
| responseText.textContent = data.message; | |
| // Demo: first response from server | |
| if (isDemoMode && demoMetrics.audioSentTime && !demoMetrics.firstResponseTime) { | |
| demoMetrics.firstResponseTime = Date.now(); | |
| } | |
| break; | |
| case 'transcription': | |
| responseText.textContent = `"${data.text}"`; | |
| // Demo: transcription arrived | |
| if (isDemoMode && demoMetrics.audioSentTime) { | |
| demoMetrics.transcriptionTime = Date.now(); | |
| console.log('[DEMO] Transcription latency:', demoMetrics.transcriptionTime - demoMetrics.audioSentTime, 'ms'); | |
| } | |
| break; | |
| case 'response': | |
| responseText.textContent = data.text; | |
| // Demo: LLM response arrived | |
| if (isDemoMode && demoMetrics.audioSentTime) { | |
| demoMetrics.llmResponseTime = Date.now(); | |
| console.log('[DEMO] LLM response latency:', demoMetrics.llmResponseTime - demoMetrics.audioSentTime, 'ms'); | |
| } | |
| break; | |
| case 'audio': | |
| const basePath = window.location.pathname.replace(/\/$/, '').replace('/rtc', ''); | |
| audioPlayer.src = basePath + data.url; | |
| audioPlayer.load(); | |
| // Demo: audio URL arrived | |
| if (isDemoMode && demoMetrics.audioSentTime) { | |
| demoMetrics.audioUrlTime = Date.now(); | |
| console.log('[DEMO] Audio URL latency:', demoMetrics.audioUrlTime - demoMetrics.audioSentTime, 'ms'); | |
| } | |
| // Track when audio actually starts playing | |
| audioPlayer.onplay = () => { | |
| if (isDemoMode) { | |
| demoMetrics.audioStartTime = Date.now(); | |
| console.log('[DEMO] Audio started at:', demoMetrics.audioStartTime - demoMetrics.audioSentTime, 'ms'); | |
| // Calculate sync offset | |
| if (demoMetrics.videoStartTime) { | |
| demoMetrics.audioVideoSyncOffset = demoMetrics.audioStartTime - demoMetrics.videoStartTime; | |
| console.log('[DEMO] Audio/Video sync offset:', demoMetrics.audioVideoSyncOffset, 'ms'); | |
| } | |
| } | |
| }; | |
| break; | |
| case 'ws_video_mode': | |
| useWsVideo = data.enabled; | |
| console.log('[WS-VIDEO] WebSocket video mode:', useWsVideo ? 'ENABLED' : 'disabled'); | |
| if (useWsVideo) { | |
| // Show canvas, hide RTC video | |
| wsVideo.style.display = 'block'; | |
| rtcVideo.style.display = 'none'; | |
| rtcStatus.className = 'status-dot'; | |
| rtcStatus.title = 'Using WebSocket video (WebRTC unavailable)'; | |
| } | |
| break; | |
| case 'video_frame': | |
| // Display frame from WebSocket | |
| if (useWsVideo) { | |
| wsVideoImage.onload = function() { | |
| // Set canvas size to match image on first frame | |
| if (wsVideo.width !== wsVideoImage.width) { | |
| wsVideo.width = wsVideoImage.width; | |
| wsVideo.height = wsVideoImage.height; | |
| } | |
| wsVideoCtx.drawImage(wsVideoImage, 0, 0); | |
| }; | |
| wsVideoImage.src = 'data:image/jpeg;base64,' + data.frame; | |
| frameCount++; | |
| statsText.textContent = `${frameCount}/${data.index + 1}`; | |
| // Track first frame timing for demo | |
| if (isDemoMode && frameCount === 1 && demoMetrics.audioSentTime && !demoMetrics.firstVideoFrameTime) { | |
| demoMetrics.firstVideoFrameTime = Date.now(); | |
| console.log('[DEMO] First WS video frame at', demoMetrics.firstVideoFrameTime - demoMetrics.audioSentTime, 'ms'); | |
| } | |
| } | |
| break; | |
| case 'info': | |
| frameCount = 0; | |
| startTime = Date.now(); | |
| statsText.textContent = `0/${data.total_frames}`; | |
| // Show video (RTC or WS canvas), hide idle | |
| if (useWsVideo) { | |
| wsVideo.classList.add('active'); | |
| wsVideo.style.display = 'block'; | |
| wsVideo.style.opacity = '1'; | |
| wsVideo.style.zIndex = '10'; | |
| } else { | |
| rtcVideo.classList.add('active'); | |
| } | |
| // Start audio when video starts | |
| audioPlayer.play().catch(e => console.log('[Audio] Autoplay blocked')); | |
| // Demo: record frame info time and expected frames | |
| if (isDemoMode && demoMetrics.audioSentTime) { | |
| demoMetrics.firstFrameInfoTime = Date.now(); | |
| demoMetrics.expectedFrames = data.total_frames; | |
| demoMetrics.videoStartTime = Date.now(); | |
| console.log('[DEMO] Frame info received at', demoMetrics.firstFrameInfoTime - demoMetrics.audioSentTime, 'ms (expecting', data.total_frames, 'frames)'); | |
| // Calculate sync offset if audio already started | |
| if (demoMetrics.audioStartTime) { | |
| demoMetrics.audioVideoSyncOffset = demoMetrics.audioStartTime - demoMetrics.videoStartTime; | |
| } | |
| } | |
| break; | |
| case 'done': | |
| const elapsed = Date.now() - startTime; | |
| latencyEl.textContent = `${elapsed}ms`; | |
| frameCountEl.textContent = data.total_frames; | |
| console.log('[Metrics] Server:', data.metrics); | |
| sessionLogs.push({time: new Date().toISOString(), metrics: data.metrics}); | |
| updateMetrics(data.metrics); | |
| // Return to idle after a delay | |
| setTimeout(() => { | |
| rtcVideo.classList.remove('active'); | |
| // Reset WebSocket video canvas | |
| if (useWsVideo) { | |
| wsVideo.style.opacity = '0'; | |
| wsVideo.style.zIndex = '1'; | |
| } | |
| }, 1000); | |
| // Demo: export final metrics | |
| if (isDemoMode) { | |
| demoMetrics.responseCompleteTime = Date.now(); | |
| demoMetrics.totalFrames = data.total_frames; | |
| demoMetrics.serverMetrics = data.metrics; | |
| console.log('[DEMO] Response complete at', demoMetrics.responseCompleteTime - demoMetrics.audioSentTime, 'ms after audio sent'); | |
| exportDemoMetrics({ success: true }); | |
| } | |
| break; | |
| case 'pong': | |
| break; | |
| case 'error': | |
| console.error('[WS] Server error:', data.message); | |
| responseText.textContent = 'Erro: ' + data.message; | |
| rtcStatus.className = 'status-dot error'; | |
| break; | |
| default: | |
| console.log('[WS] Unknown message:', data); | |
| } | |
| } | |
| // Recording | |
| recordBtn.addEventListener('mousedown', startRecording); | |
| recordBtn.addEventListener('mouseup', stopRecording); | |
| recordBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); }); | |
| recordBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); }); | |
| async function startRecording() { | |
| if (isRecording) return; | |
| if (!pc || pc.connectionState !== 'connected') { | |
| responseText.textContent = 'Aguarde conexΓ£o WebRTC...'; | |
| console.warn('[Recording] WebRTC not connected yet'); | |
| return; | |
| } | |
| isRecording = true; | |
| audioChunks = []; | |
| recordBtn.classList.add('recording'); | |
| responseText.textContent = 'Ouvindo...'; | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); | |
| mediaRecorder.ondataavailable = (e) => { | |
| if (e.data.size > 0) audioChunks.push(e.data); | |
| }; | |
| mediaRecorder.start(); | |
| } catch (e) { | |
| console.error('[Mic] Error:', e); | |
| isRecording = false; | |
| recordBtn.classList.remove('recording'); | |
| } | |
| } | |
| async function stopRecording() { | |
| if (!isRecording || !mediaRecorder) return; | |
| isRecording = false; | |
| recordBtn.classList.remove('recording'); | |
| responseText.textContent = 'Processando...'; | |
| mediaRecorder.stop(); | |
| mediaRecorder.onstop = async () => { | |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(audioBlob); | |
| reader.onloadend = () => { | |
| const base64 = reader.result.split(',')[1]; | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ | |
| type: 'start', | |
| audio_base64: base64, | |
| resolution: 256, | |
| batch_size: 16 | |
| })); | |
| } | |
| }; | |
| mediaRecorder.stream.getTracks().forEach(t => t.stop()); | |
| }; | |
| } | |
| // Metrics | |
| function toggleMetrics() { | |
| metricsPanel.classList.toggle('visible'); | |
| } | |
| function updateMetrics(serverMetrics) { | |
| let html = '<b>Server (ms):</b><br>'; | |
| if (serverMetrics && serverMetrics.timings) { | |
| for (const [k, v] of Object.entries(serverMetrics.timings)) { | |
| html += `${k}: ${v}ms<br>`; | |
| } | |
| } | |
| metricsContent.innerHTML = html; | |
| } | |
| // Demo mode functions | |
| async function startDemoMode() { | |
| if (!isDemoMode) return; | |
| console.log('[DEMO] Demo mode enabled, waiting for RTC connection...'); | |
| responseText.textContent = '[DEMO] Aguardando conexΓ£o...'; | |
| // Wait for RTC connection | |
| const waitForConnection = () => new Promise((resolve, reject) => { | |
| const timeout = setTimeout(() => reject(new Error('RTC connection timeout')), 30000); | |
| const check = () => { | |
| if (pc && pc.connectionState === 'connected') { | |
| clearTimeout(timeout); | |
| resolve(); | |
| } else { | |
| setTimeout(check, 100); | |
| } | |
| }; | |
| check(); | |
| }); | |
| try { | |
| await waitForConnection(); | |
| demoMetrics.rtcConnectTime = Date.now(); | |
| console.log('[DEMO] RTC connected in', demoMetrics.rtcConnectTime - demoMetrics.pageLoadTime, 'ms'); | |
| // Wait 2 seconds then send demo audio | |
| responseText.textContent = '[DEMO] Enviando Γ‘udio em 2s...'; | |
| await new Promise(r => setTimeout(r, 2000)); | |
| await sendDemoAudio(); | |
| } catch (e) { | |
| console.error('[DEMO] Error:', e); | |
| exportDemoMetrics({ error: e.message }); | |
| } | |
| } | |
| async function sendDemoAudio() { | |
| console.log('[DEMO] Sending demo audio...'); | |
| responseText.textContent = '[DEMO] Enviando Γ‘udio...'; | |
| // Generate a simple test audio (sine wave saying "OlΓ‘" encoded) | |
| // For real testing, we'll fetch a pre-recorded audio file | |
| const basePath = window.location.pathname.replace(/\/$/, '').replace('/rtc', ''); | |
| try { | |
| // Try to fetch demo audio file, or generate synthetic audio | |
| let audioBase64; | |
| try { | |
| const response = await fetch(basePath + '/static/demo_audio.webm'); | |
| if (response.ok) { | |
| const blob = await response.blob(); | |
| audioBase64 = await blobToBase64(blob); | |
| } else { | |
| audioBase64 = await generateSyntheticAudio(); | |
| } | |
| } catch { | |
| audioBase64 = await generateSyntheticAudio(); | |
| } | |
| demoMetrics.audioSentTime = Date.now(); | |
| if (ws && ws.readyState === WebSocket.OPEN) { | |
| ws.send(JSON.stringify({ | |
| type: 'start', | |
| audio_base64: audioBase64, | |
| resolution: 256, | |
| batch_size: 16 | |
| })); | |
| console.log('[DEMO] Audio sent at', demoMetrics.audioSentTime - demoMetrics.pageLoadTime, 'ms'); | |
| } | |
| } catch (e) { | |
| console.error('[DEMO] Failed to send audio:', e); | |
| exportDemoMetrics({ error: 'Failed to send audio: ' + e.message }); | |
| } | |
| } | |
| function blobToBase64(blob) { | |
| return new Promise((resolve) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result.split(',')[1]); | |
| reader.readAsDataURL(blob); | |
| }); | |
| } | |
| async function generateSyntheticAudio() { | |
| // Generate a short synthetic audio (WebM with silence + tone) | |
| // This creates a valid WebM audio that the server can process | |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| const sampleRate = audioContext.sampleRate; | |
| const duration = 1.5; // 1.5 seconds | |
| const buffer = audioContext.createBuffer(1, sampleRate * duration, sampleRate); | |
| const data = buffer.getChannelData(0); | |
| // Generate a simple tone (like someone saying "OlΓ‘") | |
| for (let i = 0; i < data.length; i++) { | |
| const t = i / sampleRate; | |
| // Simple envelope and frequency modulation to simulate speech | |
| const envelope = Math.sin(Math.PI * t / duration); | |
| const freq = 200 + 100 * Math.sin(2 * Math.PI * 2 * t); | |
| data[i] = envelope * 0.3 * Math.sin(2 * Math.PI * freq * t); | |
| } | |
| // Record to WebM using MediaRecorder | |
| const dest = audioContext.createMediaStreamDestination(); | |
| const source = audioContext.createBufferSource(); | |
| source.buffer = buffer; | |
| source.connect(dest); | |
| source.start(); | |
| const recorder = new MediaRecorder(dest.stream, { mimeType: 'audio/webm' }); | |
| const chunks = []; | |
| return new Promise((resolve) => { | |
| recorder.ondataavailable = (e) => chunks.push(e.data); | |
| recorder.onstop = async () => { | |
| const blob = new Blob(chunks, { type: 'audio/webm' }); | |
| resolve(await blobToBase64(blob)); | |
| }; | |
| recorder.start(); | |
| setTimeout(() => recorder.stop(), duration * 1000 + 100); | |
| }); | |
| } | |
| function exportDemoMetrics(extra = {}) { | |
| // Calculate average frame gap | |
| if (demoMetrics.frameTimings.length > 0) { | |
| const sum = demoMetrics.frameTimings.reduce((a, b) => a + b, 0); | |
| demoMetrics.avgFrameGap = Math.round(sum / demoMetrics.frameTimings.length); | |
| } | |
| // Calculate dropped frames (expected - received) | |
| if (demoMetrics.expectedFrames > 0) { | |
| demoMetrics.droppedFrames = Math.max(0, demoMetrics.expectedFrames - demoMetrics.totalFrames); | |
| } | |
| const audioSent = demoMetrics.audioSentTime; | |
| const metrics = { | |
| ...extra, | |
| timestamp: new Date().toISOString(), | |
| // === LATΓNCIA DE RESPOSTA (mΓ©trica principal) === | |
| // Tempo entre enviar Γ‘udio e comeΓ§ar a receber resposta (Γ‘udio+vΓdeo) | |
| // Conversa humana natural: ~250ms | |
| responseLatency: audioSent && demoMetrics.firstFrameInfoTime | |
| ? demoMetrics.firstFrameInfoTime - audioSent : null, | |
| // === LATΓNCIAS DETALHADAS (a partir do envio do Γ‘udio) === | |
| latency: { | |
| // Tempo atΓ© primeira resposta do servidor | |
| toFirstResponse: audioSent && demoMetrics.firstResponseTime | |
| ? demoMetrics.firstResponseTime - audioSent : null, | |
| // Tempo atΓ© transcriΓ§Γ£o (STT completo) | |
| toTranscription: audioSent && demoMetrics.transcriptionTime | |
| ? demoMetrics.transcriptionTime - audioSent : null, | |
| // Tempo atΓ© resposta do LLM (texto da resposta pronto) | |
| toLLMResponse: audioSent && demoMetrics.llmResponseTime | |
| ? demoMetrics.llmResponseTime - audioSent : null, | |
| // Tempo atΓ© URL do Γ‘udio (TTS completo) | |
| toAudioUrl: audioSent && demoMetrics.audioUrlTime | |
| ? demoMetrics.audioUrlTime - audioSent : null, | |
| // *** LATΓNCIA DE RESPOSTA: quando Γ‘udio+vΓdeo comeΓ§am *** | |
| toFirstPlayback: audioSent && demoMetrics.firstFrameInfoTime | |
| ? demoMetrics.firstFrameInfoTime - audioSent : null, | |
| // Tempo atΓ© primeiro frame de vΓdeo renderizado | |
| toFirstVideoFrame: audioSent && demoMetrics.firstVideoFrameTime | |
| ? demoMetrics.firstVideoFrameTime - audioSent : null, | |
| // Tempo total atΓ© completar | |
| toComplete: audioSent && demoMetrics.responseCompleteTime | |
| ? demoMetrics.responseCompleteTime - audioSent : null | |
| }, | |
| // === QUALIDADE DO VΓDEO === | |
| videoQuality: { | |
| totalFrames: demoMetrics.totalFrames, | |
| expectedFrames: demoMetrics.expectedFrames, | |
| droppedFrames: demoMetrics.droppedFrames, | |
| dropRate: demoMetrics.expectedFrames > 0 | |
| ? ((demoMetrics.droppedFrames / demoMetrics.expectedFrames) * 100).toFixed(1) + '%' : null, | |
| stutterCount: demoMetrics.stutterCount, | |
| maxFrameGap: demoMetrics.maxFrameGap, | |
| avgFrameGap: demoMetrics.avgFrameGap, | |
| isSmooth: demoMetrics.stutterCount === 0 && demoMetrics.maxFrameGap < 100 | |
| }, | |
| // === SINCRONIZAΓΓO ΓUDIO/VΓDEO === | |
| sync: { | |
| audioStartLatency: audioSent && demoMetrics.audioStartTime | |
| ? demoMetrics.audioStartTime - audioSent : null, | |
| videoStartLatency: audioSent && demoMetrics.videoStartTime | |
| ? demoMetrics.videoStartTime - audioSent : null, | |
| audioVideoOffset: demoMetrics.audioVideoSyncOffset, | |
| isSynced: demoMetrics.audioVideoSyncOffset !== null | |
| ? Math.abs(demoMetrics.audioVideoSyncOffset) < 100 : null | |
| }, | |
| // === CONEXΓO === | |
| connection: { | |
| wsConnectTime: demoMetrics.wsConnectTime ? demoMetrics.wsConnectTime - demoMetrics.pageLoadTime : null, | |
| rtcConnectTime: demoMetrics.rtcConnectTime ? demoMetrics.rtcConnectTime - demoMetrics.pageLoadTime : null | |
| }, | |
| // === MΓTRICAS DO SERVIDOR === | |
| serverMetrics: demoMetrics.serverMetrics, | |
| // === AVALIAΓΓO === | |
| evaluation: { | |
| humanLatencyBenchmark: 250, // ms - conversa humana natural | |
| responseLatency: audioSent && demoMetrics.firstFrameInfoTime | |
| ? demoMetrics.firstFrameInfoTime - audioSent : null, | |
| latencyRatio: audioSent && demoMetrics.firstFrameInfoTime | |
| ? parseFloat(((demoMetrics.firstFrameInfoTime - audioSent) / 250).toFixed(1)) : null, | |
| latencyGrade: (() => { | |
| const lat = audioSent && demoMetrics.firstFrameInfoTime | |
| ? demoMetrics.firstFrameInfoTime - audioSent : null; | |
| if (!lat) return 'N/A'; | |
| if (lat <= 500) return 'A'; // Excelente | |
| if (lat <= 1000) return 'B'; // Bom | |
| if (lat <= 2000) return 'C'; // AceitΓ‘vel | |
| if (lat <= 3000) return 'D'; // Ruim | |
| return 'F'; // InaceitΓ‘vel | |
| })(), | |
| isAcceptable: audioSent && demoMetrics.firstFrameInfoTime | |
| ? (demoMetrics.firstFrameInfoTime - audioSent) <= 2000 : false | |
| }, | |
| // === RESULTADO === | |
| success: extra.success || false, | |
| error: extra.error || null, | |
| conversationFluid: demoMetrics.stutterCount === 0 | |
| && audioSent && demoMetrics.firstFrameInfoTime | |
| && (demoMetrics.firstFrameInfoTime - audioSent) < 3000 // < 3s para primeiro frame | |
| && (demoMetrics.audioVideoSyncOffset === null || Math.abs(demoMetrics.audioVideoSyncOffset) < 200) | |
| }; | |
| console.log('[DEMO] === METRICS ==='); | |
| console.log(JSON.stringify(metrics, null, 2)); | |
| // Expose globally for Playwright to read | |
| window.__DEMO_METRICS__ = metrics; | |
| // Calcular avaliaΓ§Γ£o da latΓͺncia | |
| const humanLatency = 250; // ms - latΓͺncia natural conversa humana | |
| const latencyRatio = metrics.responseLatency ? (metrics.responseLatency / humanLatency).toFixed(1) : 'N/A'; | |
| const latencyGrade = metrics.responseLatency | |
| ? (metrics.responseLatency <= 500 ? 'A' : | |
| metrics.responseLatency <= 1000 ? 'B' : | |
| metrics.responseLatency <= 2000 ? 'C' : | |
| metrics.responseLatency <= 3000 ? 'D' : 'F') | |
| : 'N/A'; | |
| // Create summary for display | |
| const summary = ` | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β LATΓNCIA DE RESPOSTA (Γ‘udio enviado β resposta comeΓ§a) β | |
| β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£ | |
| β >>> ${metrics.responseLatency || 'N/A'} ms <<< β | |
| β β | |
| β ReferΓͺncia conversa humana: 250 ms β | |
| β RazΓ£o: ${latencyRatio}x mais lento que humano β | |
| β Nota: ${latencyGrade} β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| === BREAKDOWN DA LATΓNCIA === | |
| TranscriΓ§Γ£o (STT): ${metrics.latency.toTranscription || 'N/A'} ms | |
| Resposta LLM: ${metrics.latency.toLLMResponse || 'N/A'} ms | |
| SΓntese Voz (TTS): ${metrics.latency.toAudioUrl || 'N/A'} ms | |
| Primeiro Playback: ${metrics.latency.toFirstPlayback || 'N/A'} ms | |
| Total Completo: ${metrics.latency.toComplete || 'N/A'} ms | |
| === QUALIDADE DO VΓDEO === | |
| Frames: ${metrics.videoQuality.totalFrames}/${metrics.videoQuality.expectedFrames} | |
| Travamentos: ${metrics.videoQuality.stutterCount} | |
| Max gap: ${metrics.videoQuality.maxFrameGap} ms | |
| Fluido: ${metrics.videoQuality.isSmooth ? 'β SIM' : 'β NΓO'} | |
| === SINCRONIZAΓΓO === | |
| Offset A/V: ${metrics.sync.audioVideoOffset || 'N/A'} ms | |
| Sincronizado: ${metrics.sync.isSynced ? 'β SIM' : 'β NΓO'} | |
| === RESULTADO FINAL === | |
| Conversa Fluida: ${metrics.conversationFluid ? 'β SIM' : 'β NΓO'} | |
| `; | |
| // Also log to a special element for easy extraction | |
| let metricsEl = document.getElementById('demo-metrics-output'); | |
| if (!metricsEl) { | |
| metricsEl = document.createElement('pre'); | |
| metricsEl.id = 'demo-metrics-output'; | |
| metricsEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#000;color:#0f0;font-size:10px;padding:5px;max-height:200px;overflow:auto;z-index:9999;'; | |
| document.body.appendChild(metricsEl); | |
| } | |
| metricsEl.textContent = summary + '\n\nJSON:\n' + JSON.stringify(metrics, null, 2); | |
| return metrics; | |
| } | |
| // Start | |
| init(); | |
| // Start demo mode after init | |
| if (isDemoMode) { | |
| console.log('[DEMO] Demo mode detected, will auto-start'); | |
| startDemoMode(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |