| <!DOCTYPE html> |
| <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> |
| |
| 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'); |
| |
| |
| 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'); |
| |
| |
| let ws = null; |
| let pc = null; |
| let isRecording = false; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let frameCount = 0; |
| let startTime = 0; |
| let sessionLogs = []; |
| |
| |
| const urlParams = new URLSearchParams(window.location.search); |
| const isDemoMode = urlParams.get('demo') === 'true'; |
| const forceRelay = urlParams.get('relay') === 'true'; |
| const demoMetrics = { |
| pageLoadTime: Date.now(), |
| wsConnectTime: null, |
| rtcConnectTime: null, |
| |
| audioSentTime: null, |
| firstResponseTime: null, |
| transcriptionTime: null, |
| llmResponseTime: null, |
| audioUrlTime: null, |
| firstFrameInfoTime: null, |
| firstVideoFrameTime: null, |
| responseCompleteTime: null, |
| |
| totalFrames: 0, |
| expectedFrames: 0, |
| droppedFrames: 0, |
| frameTimings: [], |
| stutterCount: 0, |
| maxFrameGap: 0, |
| avgFrameGap: 0, |
| |
| audioStartTime: null, |
| videoStartTime: null, |
| audioVideoSyncOffset: null, |
| |
| serverMetrics: null |
| }; |
| |
| |
| async function init() { |
| connectWebSocket(); |
| await setupWebRTC(); |
| } |
| |
| |
| 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'; |
| |
| if (isDemoMode) { |
| demoMetrics.wsConnectTime = Date.now(); |
| console.log('[DEMO] WS connected in', demoMetrics.wsConnectTime - demoMetrics.pageLoadTime, 'ms'); |
| } |
| |
| 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); |
| }; |
| |
| |
| setInterval(() => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({type: 'ping'})); |
| } |
| }, 10000); |
| } |
| |
| |
| async function setupWebRTC() { |
| pc = new RTCPeerConnection({ |
| iceServers: [ |
| |
| { urls: 'stun:stun.l.google.com:19302' }, |
| { urls: 'stun:stun1.l.google.com:19302' }, |
| { urls: 'stun:stun.services.mozilla.com' }, |
| |
| |
| { |
| urls: 'turn:openrelay.metered.ca:80', |
| username: 'openrelayproject', |
| credential: 'openrelayproject' |
| }, |
| |
| { |
| urls: 'turn:openrelay.metered.ca:443?transport=tcp', |
| username: 'openrelayproject', |
| credential: 'openrelayproject' |
| }, |
| |
| { |
| urls: 'turns:openrelay.metered.ca:443?transport=tcp', |
| username: 'openrelayproject', |
| credential: 'openrelayproject' |
| }, |
| |
| { |
| 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' |
| }); |
| |
| 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'; |
| |
| |
| if (isDemoMode) { |
| let lastFrameTime = null; |
| let frameIndex = 0; |
| |
| |
| 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'); |
| } |
| |
| |
| if (lastFrameTime) { |
| const gap = currentTime - lastFrameTime; |
| demoMetrics.frameTimings.push(gap); |
| |
| |
| 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; |
| |
| |
| rtcVideo.requestVideoFrameCallback(trackFrame); |
| }; |
| rtcVideo.requestVideoFrameCallback(trackFrame); |
| } else { |
| |
| 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) => { |
| |
| }; |
| |
| 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...'; |
| } |
| }; |
| |
| |
| 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); |
| |
| |
| |
| console.log('[RTC] Gathering ICE candidates (trickle mode)...'); |
| |
| let candidateCount = 0; |
| const gatheringPromise = new Promise(resolve => { |
| |
| pc.onicecandidate = (event) => { |
| if (event.candidate) { |
| candidateCount++; |
| console.log('[RTC] ICE candidate', candidateCount, ':', event.candidate.type, event.candidate.protocol); |
| } else { |
| |
| console.log('[RTC] ICE gathering complete, total candidates:', candidateCount); |
| resolve(); |
| } |
| }; |
| |
| |
| pc.onicegatheringstatechange = () => { |
| console.log('[RTC] ICE gathering state:', pc.iceGatheringState); |
| if (pc.iceGatheringState === 'complete') resolve(); |
| }; |
| }); |
| |
| |
| |
| await Promise.race([ |
| gatheringPromise, |
| new Promise(resolve => setTimeout(() => { |
| console.log('[RTC] Timeout waiting for ICE, sending with', candidateCount, 'candidates'); |
| resolve(); |
| }, 5000)) |
| ]); |
| |
| |
| 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; |
| |
| if (isDemoMode && demoMetrics.audioSentTime && !demoMetrics.firstResponseTime) { |
| demoMetrics.firstResponseTime = Date.now(); |
| } |
| break; |
| |
| case 'transcription': |
| responseText.textContent = `"${data.text}"`; |
| |
| 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; |
| |
| 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(); |
| |
| if (isDemoMode && demoMetrics.audioSentTime) { |
| demoMetrics.audioUrlTime = Date.now(); |
| console.log('[DEMO] Audio URL latency:', demoMetrics.audioUrlTime - demoMetrics.audioSentTime, 'ms'); |
| } |
| |
| audioPlayer.onplay = () => { |
| if (isDemoMode) { |
| demoMetrics.audioStartTime = Date.now(); |
| console.log('[DEMO] Audio started at:', demoMetrics.audioStartTime - demoMetrics.audioSentTime, 'ms'); |
| |
| 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) { |
| |
| wsVideo.style.display = 'block'; |
| rtcVideo.style.display = 'none'; |
| rtcStatus.className = 'status-dot'; |
| rtcStatus.title = 'Using WebSocket video (WebRTC unavailable)'; |
| } |
| break; |
| |
| case 'video_frame': |
| |
| if (useWsVideo) { |
| wsVideoImage.onload = function() { |
| |
| 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}`; |
| |
| |
| 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}`; |
| |
| if (useWsVideo) { |
| wsVideo.classList.add('active'); |
| wsVideo.style.display = 'block'; |
| wsVideo.style.opacity = '1'; |
| wsVideo.style.zIndex = '10'; |
| } else { |
| rtcVideo.classList.add('active'); |
| } |
| |
| audioPlayer.play().catch(e => console.log('[Audio] Autoplay blocked')); |
| |
| 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)'); |
| |
| 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); |
| |
| setTimeout(() => { |
| rtcVideo.classList.remove('active'); |
| |
| if (useWsVideo) { |
| wsVideo.style.opacity = '0'; |
| wsVideo.style.zIndex = '1'; |
| } |
| }, 1000); |
| |
| 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); |
| } |
| } |
| |
| |
| 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()); |
| }; |
| } |
| |
| |
| 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; |
| } |
| |
| |
| async function startDemoMode() { |
| if (!isDemoMode) return; |
| console.log('[DEMO] Demo mode enabled, waiting for RTC connection...'); |
| responseText.textContent = '[DEMO] Aguardando conexΓ£o...'; |
| |
| |
| 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'); |
| |
| |
| 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...'; |
| |
| |
| |
| const basePath = window.location.pathname.replace(/\/$/, '').replace('/rtc', ''); |
| |
| try { |
| |
| 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() { |
| |
| |
| const audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| const sampleRate = audioContext.sampleRate; |
| const duration = 1.5; |
| const buffer = audioContext.createBuffer(1, sampleRate * duration, sampleRate); |
| const data = buffer.getChannelData(0); |
| |
| |
| for (let i = 0; i < data.length; i++) { |
| const t = i / sampleRate; |
| |
| 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); |
| } |
| |
| |
| 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 = {}) { |
| |
| if (demoMetrics.frameTimings.length > 0) { |
| const sum = demoMetrics.frameTimings.reduce((a, b) => a + b, 0); |
| demoMetrics.avgFrameGap = Math.round(sum / demoMetrics.frameTimings.length); |
| } |
| |
| |
| if (demoMetrics.expectedFrames > 0) { |
| demoMetrics.droppedFrames = Math.max(0, demoMetrics.expectedFrames - demoMetrics.totalFrames); |
| } |
| |
| const audioSent = demoMetrics.audioSentTime; |
| |
| const metrics = { |
| ...extra, |
| timestamp: new Date().toISOString(), |
| |
| |
| |
| |
| responseLatency: audioSent && demoMetrics.firstFrameInfoTime |
| ? demoMetrics.firstFrameInfoTime - audioSent : null, |
| |
| |
| latency: { |
| |
| toFirstResponse: audioSent && demoMetrics.firstResponseTime |
| ? demoMetrics.firstResponseTime - audioSent : null, |
| |
| toTranscription: audioSent && demoMetrics.transcriptionTime |
| ? demoMetrics.transcriptionTime - audioSent : null, |
| |
| toLLMResponse: audioSent && demoMetrics.llmResponseTime |
| ? demoMetrics.llmResponseTime - audioSent : null, |
| |
| toAudioUrl: audioSent && demoMetrics.audioUrlTime |
| ? demoMetrics.audioUrlTime - audioSent : null, |
| |
| toFirstPlayback: audioSent && demoMetrics.firstFrameInfoTime |
| ? demoMetrics.firstFrameInfoTime - audioSent : null, |
| |
| toFirstVideoFrame: audioSent && demoMetrics.firstVideoFrameTime |
| ? demoMetrics.firstVideoFrameTime - audioSent : null, |
| |
| toComplete: audioSent && demoMetrics.responseCompleteTime |
| ? demoMetrics.responseCompleteTime - audioSent : null |
| }, |
| |
| |
| 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 |
| }, |
| |
| |
| 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 |
| }, |
| |
| |
| connection: { |
| wsConnectTime: demoMetrics.wsConnectTime ? demoMetrics.wsConnectTime - demoMetrics.pageLoadTime : null, |
| rtcConnectTime: demoMetrics.rtcConnectTime ? demoMetrics.rtcConnectTime - demoMetrics.pageLoadTime : null |
| }, |
| |
| |
| serverMetrics: demoMetrics.serverMetrics, |
| |
| |
| evaluation: { |
| humanLatencyBenchmark: 250, |
| 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'; |
| if (lat <= 1000) return 'B'; |
| if (lat <= 2000) return 'C'; |
| if (lat <= 3000) return 'D'; |
| return 'F'; |
| })(), |
| isAcceptable: audioSent && demoMetrics.firstFrameInfoTime |
| ? (demoMetrics.firstFrameInfoTime - audioSent) <= 2000 : false |
| }, |
| |
| |
| success: extra.success || false, |
| error: extra.error || null, |
| conversationFluid: demoMetrics.stutterCount === 0 |
| && audioSent && demoMetrics.firstFrameInfoTime |
| && (demoMetrics.firstFrameInfoTime - audioSent) < 3000 |
| && (demoMetrics.audioVideoSyncOffset === null || Math.abs(demoMetrics.audioVideoSyncOffset) < 200) |
| }; |
| |
| console.log('[DEMO] === METRICS ==='); |
| console.log(JSON.stringify(metrics, null, 2)); |
| |
| |
| window.__DEMO_METRICS__ = metrics; |
| |
| |
| const humanLatency = 250; |
| 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'; |
| |
| |
| 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'} |
| `; |
| |
| |
| 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; |
| } |
| |
| |
| init(); |
| |
| |
| if (isDemoMode) { |
| console.log('[DEMO] Demo mode detected, will auto-start'); |
| startDemoMode(); |
| } |
| </script> |
| </body> |
| </html> |
|
|