| <!DOCTYPE html> |
| <html lang="pt-BR"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Avatar - WebRTC</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: system-ui, sans-serif; |
| background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%); |
| color: #fff; |
| min-height: 100vh; |
| padding: 20px; |
| } |
| .container { max-width: 900px; margin: 0 auto; } |
| |
| .status { |
| text-align: center; |
| padding: 10px; |
| margin-bottom: 15px; |
| border-radius: 8px; |
| font-size: 14px; |
| background: rgba(255,255,255,0.1); |
| } |
| .status.connected { background: rgba(0,255,100,0.2); color: #0f0; } |
| .status.busy { background: rgba(255,200,0,0.2); color: #fc0; } |
| .status.error { background: rgba(255,0,0,0.2); color: #f55; } |
| |
| .video-box { |
| background: #000; |
| border-radius: 10px; |
| overflow: hidden; |
| margin-bottom: 20px; |
| aspect-ratio: 16/9; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| position: relative; |
| } |
| video { |
| max-width: 100%; |
| max-height: 100%; |
| object-fit: contain; |
| } |
| .placeholder { |
| color: #666; |
| font-size: 14px; |
| position: absolute; |
| } |
| |
| .controls { |
| display: flex; |
| gap: 10px; |
| margin-bottom: 15px; |
| flex-wrap: wrap; |
| } |
| |
| .call-controls { |
| display: flex; |
| gap: 10px; |
| margin-bottom: 20px; |
| } |
| |
| textarea { |
| flex: 1; |
| min-width: 200px; |
| padding: 12px; |
| border: 1px solid #333; |
| border-radius: 8px; |
| background: #1a1a2e; |
| color: #fff; |
| font-size: 14px; |
| resize: none; |
| height: 60px; |
| } |
| select { |
| padding: 12px; |
| border: 1px solid #333; |
| border-radius: 8px; |
| background: #1a1a2e; |
| color: #fff; |
| font-size: 14px; |
| } |
| button { |
| padding: 12px 24px; |
| border: none; |
| border-radius: 8px; |
| font-size: 14px; |
| font-weight: bold; |
| cursor: pointer; |
| transition: opacity 0.2s; |
| } |
| button:hover { opacity: 0.9; } |
| button:disabled { opacity: 0.5; cursor: not-allowed; } |
| |
| .btn-call { background: #00aaff; color: #fff; } |
| .btn-call.active { background: #ff4444; } |
| .btn-generate { background: #00ff88; color: #000; flex: 0 0 auto; } |
| |
| .metrics { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); |
| gap: 8px; |
| padding: 12px; |
| background: #1a1a2e; |
| border-radius: 8px; |
| font-size: 12px; |
| } |
| .metric { display: flex; justify-content: space-between; } |
| .val { color: #00ff88; font-family: monospace; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <div class="status" id="status">Desconectado</div> |
|
|
| <div class="video-box"> |
| <video id="video" autoplay playsinline></video> |
| <div class="placeholder" id="placeholder">Clique em "Conectar" para iniciar</div> |
| </div> |
|
|
| <div class="call-controls"> |
| <button class="btn-call" id="btnConnect">Conectar</button> |
| </div> |
|
|
| <div class="controls"> |
| <textarea id="text" placeholder="Digite o texto para o avatar falar...">Hello! I am testing the WebRTC streaming avatar with VP9 codec.</textarea> |
| <select id="voice"> |
| <option value="tara">Tara</option> |
| <option value="leah">Leah</option> |
| <option value="jess">Jess</option> |
| <option value="leo">Leo</option> |
| <option value="dan">Dan</option> |
| </select> |
| <button class="btn-generate" id="btnGenerate" disabled>Gerar</button> |
| </div> |
|
|
| <div class="metrics"> |
| <div class="metric"><span>WebRTC:</span><span class="val" id="mWebrtc">--</span></div> |
| <div class="metric"><span>Video:</span><span class="val" id="mVideo">--</span></div> |
| <div class="metric"><span>Audio:</span><span class="val" id="mAudio">--</span></div> |
| <div class="metric"><span>Latencia:</span><span class="val" id="mLatency">--</span></div> |
| </div> |
| </div> |
|
|
| <script> |
| const video = document.getElementById('video'); |
| const status = document.getElementById('status'); |
| const placeholder = document.getElementById('placeholder'); |
| const btnConnect = document.getElementById('btnConnect'); |
| const btnGenerate = document.getElementById('btnGenerate'); |
| |
| |
| window.pc = null; |
| let pc = null; |
| let sessionId = null; |
| let isConnected = false; |
| let isConnecting = false; |
| |
| function setStatus(txt, cls) { |
| status.textContent = txt; |
| status.className = 'status ' + (cls || ''); |
| } |
| |
| function setMetric(id, val) { |
| document.getElementById(id).textContent = val; |
| } |
| |
| async function connect() { |
| if (isConnected) { |
| disconnect(); |
| return; |
| } |
| |
| |
| if (isConnecting) { |
| console.log('Conexão já em andamento, ignorando...'); |
| return; |
| } |
| |
| isConnecting = true; |
| setStatus('Conectando...', 'busy'); |
| |
| try { |
| |
| pc = window.pc = new RTCPeerConnection({ |
| iceServers: [ |
| { urls: 'stun:stun.l.google.com:19302' }, |
| |
| { |
| urls: [ |
| 'turn:openrelay.metered.ca:80', |
| 'turn:openrelay.metered.ca:443', |
| 'turn:openrelay.metered.ca:443?transport=tcp' |
| ], |
| username: 'openrelayproject', |
| credential: 'openrelayproject' |
| }, |
| |
| { |
| urls: 'turn:global.turn.twilio.com:3478?transport=udp', |
| username: 'f4b4035eaa76f4a55de5f4351567653ee4ff6fa97b50b6b334fcc1be9c27212d', |
| credential: 'w1uxM55V9yVoqyVFjt+mxDBV0F87AUCemaYVQGxsPLw=' |
| } |
| ], |
| iceCandidatePoolSize: 10, |
| bundlePolicy: 'max-bundle', |
| rtcpMuxPolicy: 'require' |
| }); |
| |
| |
| pc.ontrack = (event) => { |
| console.log('Track recebido:', event.track.kind); |
| if (event.track.kind === 'video') { |
| video.srcObject = event.streams[0]; |
| placeholder.style.display = 'none'; |
| setMetric('mVideo', 'Ativo'); |
| } |
| if (event.track.kind === 'audio') { |
| setMetric('mAudio', 'Ativo'); |
| } |
| }; |
| |
| |
| pc.onconnectionstatechange = () => { |
| console.log('Estado WebRTC:', pc.connectionState); |
| setMetric('mWebrtc', pc.connectionState); |
| |
| if (pc.connectionState === 'connected') { |
| isConnected = true; |
| isConnecting = false; |
| updateConnectButton(); |
| setStatus('Conectado - Streaming ativo', 'connected'); |
| btnGenerate.disabled = false; |
| startStatsMonitor(); |
| } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { |
| isConnecting = false; |
| disconnect(); |
| } |
| }; |
| |
| pc.oniceconnectionstatechange = () => { |
| console.log('ICE State:', pc.iceConnectionState); |
| }; |
| |
| |
| pc.onicecandidate = (event) => { |
| if (event.candidate) { |
| console.log('ICE Candidate:', { |
| type: event.candidate.type, |
| protocol: event.candidate.protocol, |
| address: event.candidate.address, |
| port: event.candidate.port |
| }); |
| } |
| }; |
| |
| |
| pc.addTransceiver('video', { direction: 'recvonly' }); |
| pc.addTransceiver('audio', { direction: 'recvonly' }); |
| |
| |
| const offer = await pc.createOffer(); |
| await pc.setLocalDescription(offer); |
| |
| |
| |
| await new Promise(resolve => setTimeout(resolve, 500)); |
| |
| console.log('Enviando offer para servidor...'); |
| |
| |
| const response = await fetch('/offer', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| sdp: pc.localDescription.sdp, |
| type: pc.localDescription.type |
| }) |
| }); |
| |
| if (!response.ok) { |
| throw new Error('Erro ao conectar: ' + response.status); |
| } |
| |
| const answer = await response.json(); |
| sessionId = answer.session_id; |
| |
| |
| await pc.setRemoteDescription(new RTCSessionDescription({ |
| sdp: answer.sdp, |
| type: answer.type |
| })); |
| |
| console.log('Session ID:', sessionId); |
| |
| } catch (err) { |
| console.error('Erro ao conectar:', err); |
| isConnecting = false; |
| setStatus('Erro: ' + err.message, 'error'); |
| disconnect(); |
| } |
| } |
| |
| function disconnect() { |
| if (pc) { |
| pc.close(); |
| pc = null; |
| } |
| |
| isConnected = false; |
| sessionId = null; |
| video.srcObject = null; |
| placeholder.style.display = 'block'; |
| |
| updateConnectButton(); |
| btnGenerate.disabled = true; |
| |
| setStatus('Desconectado'); |
| setMetric('mWebrtc', '--'); |
| setMetric('mVideo', '--'); |
| setMetric('mAudio', '--'); |
| setMetric('mLatency', '--'); |
| } |
| |
| function updateConnectButton() { |
| if (isConnected) { |
| btnConnect.textContent = 'Desconectar'; |
| btnConnect.classList.add('active'); |
| } else { |
| btnConnect.textContent = 'Conectar'; |
| btnConnect.classList.remove('active'); |
| } |
| } |
| |
| async function generate() { |
| const text = document.getElementById('text').value.trim(); |
| if (!text || !sessionId) return; |
| |
| setStatus('Gerando fala...', 'busy'); |
| btnGenerate.disabled = true; |
| |
| try { |
| const response = await fetch('/generate', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| session_id: sessionId, |
| text: text, |
| voice: document.getElementById('voice').value |
| }) |
| }); |
| |
| if (!response.ok) { |
| const err = await response.json(); |
| throw new Error(err.error || 'Erro ao gerar'); |
| } |
| |
| setStatus('Reproduzindo...', 'connected'); |
| |
| } catch (err) { |
| console.error('Erro ao gerar:', err); |
| setStatus('Erro: ' + err.message, 'error'); |
| } finally { |
| btnGenerate.disabled = false; |
| } |
| } |
| |
| function startStatsMonitor() { |
| setInterval(async () => { |
| if (!pc || !isConnected) return; |
| |
| try { |
| const stats = await pc.getStats(); |
| stats.forEach(report => { |
| if (report.type === 'inbound-rtp' && report.kind === 'video') { |
| const fps = report.framesPerSecond || 0; |
| const width = report.frameWidth || 0; |
| const height = report.frameHeight || 0; |
| if (width && height) { |
| setMetric('mVideo', `${width}x${height} @${fps.toFixed(0)}fps`); |
| } |
| } |
| if (report.type === 'candidate-pair' && report.state === 'succeeded') { |
| const rtt = report.currentRoundTripTime; |
| if (rtt) { |
| setMetric('mLatency', `${(rtt * 1000).toFixed(0)}ms`); |
| } |
| } |
| }); |
| } catch (e) { |
| |
| } |
| }, 1000); |
| } |
| |
| |
| btnConnect.onclick = connect; |
| btnGenerate.onclick = generate; |
| |
| |
| document.getElementById('text').onkeydown = (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| if (!btnGenerate.disabled) { |
| generate(); |
| } |
| } |
| }; |
| </script> |
| </body> |
| </html> |
|
|