| <!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 - LiveKit</title> |
| <script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script> |
| <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); |
| } |
| #livekit-video { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| border-radius: 24px; |
| } |
| .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; |
| } |
| .mic-btn { |
| width: 64px; |
| height: 64px; |
| border-radius: 50%; |
| border: none; |
| background: linear-gradient(145deg, #667eea, #764ba2); |
| color: white; |
| font-size: 24px; |
| cursor: pointer; |
| transition: all 0.3s; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| .mic-btn:hover { transform: scale(1.05); } |
| .mic-btn.recording { |
| background: linear-gradient(145deg, #f44336, #e91e63); |
| animation: pulse-record 1s infinite; |
| } |
| .mic-btn:disabled { |
| background: #444; |
| cursor: not-allowed; |
| } |
| @keyframes pulse-record { |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.5); } |
| 50% { box-shadow: 0 0 0 15px rgba(244, 67, 54, 0); } |
| } |
| .debug-panel { |
| margin-top: 15px; |
| padding: 10px; |
| background: rgba(255,255,255,0.05); |
| border-radius: 8px; |
| font-size: 11px; |
| color: #888; |
| } |
| .hidden { display: none; } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <div class="avatar-section"> |
| <video id="livekit-video" autoplay playsinline muted></video> |
|
|
| <div class="status-bar"> |
| <div> |
| <span class="status-dot" id="statusDot"></span> |
| <span id="statusText">Conectando...</span> |
| </div> |
| <div id="streamInfo"></div> |
| </div> |
|
|
| <div class="response-overlay"> |
| <div class="response-text hidden" id="responseText"></div> |
| </div> |
| </div> |
|
|
| <div class="controls"> |
| <button class="mic-btn" id="micBtn" disabled> |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> |
| <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/> |
| <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/> |
| </svg> |
| </button> |
| </div> |
|
|
| <div class="debug-panel"> |
| <div>Session: <span id="sessionId">-</span></div> |
| <div>LiveKit: <span id="livekitStatus">Desconectado</span></div> |
| <div>Room: <span id="roomName">-</span></div> |
| </div> |
| </div> |
|
|
| <script> |
| const { Room, RoomEvent, ConnectionState } = LivekitClient; |
| |
| |
| let room = null; |
| let sessionId = Math.random().toString(36).substring(2, 10); |
| let ws = null; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| |
| |
| const video = document.getElementById('livekit-video'); |
| const micBtn = document.getElementById('micBtn'); |
| const statusDot = document.getElementById('statusDot'); |
| const statusText = document.getElementById('statusText'); |
| const streamInfo = document.getElementById('streamInfo'); |
| const responseText = document.getElementById('responseText'); |
| const livekitStatus = document.getElementById('livekitStatus'); |
| const roomNameEl = document.getElementById('roomName'); |
| |
| document.getElementById('sessionId').textContent = sessionId; |
| |
| |
| async function init() { |
| try { |
| |
| const configRes = await fetch('/api/livekit/config'); |
| const config = await configRes.json(); |
| |
| if (!config.enabled) { |
| setStatus('error', 'LiveKit nao disponivel'); |
| return; |
| } |
| |
| |
| const tokenRes = await fetch(`/api/livekit/token/${sessionId}`); |
| const tokenData = await tokenRes.json(); |
| |
| roomNameEl.textContent = tokenData.room_name; |
| |
| |
| room = new Room({ |
| adaptiveStream: true, |
| dynacast: true, |
| }); |
| |
| room.on(RoomEvent.Connected, () => { |
| console.log('[LiveKit] Connected to room'); |
| livekitStatus.textContent = 'Conectado'; |
| setStatus('connected', 'Conectado'); |
| }); |
| |
| room.on(RoomEvent.Disconnected, () => { |
| console.log('[LiveKit] Disconnected'); |
| livekitStatus.textContent = 'Desconectado'; |
| setStatus('error', 'Desconectado'); |
| }); |
| |
| room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { |
| console.log('[LiveKit] Track subscribed:', track.kind); |
| if (track.kind === 'video') { |
| track.attach(video); |
| setStatus('streaming', 'Recebendo video'); |
| } |
| }); |
| |
| room.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { |
| console.log('[LiveKit] Track unsubscribed'); |
| track.detach(); |
| }); |
| |
| await room.connect(tokenData.url, tokenData.token); |
| |
| |
| connectWebSocket(); |
| |
| } catch (error) { |
| console.error('[Init] Error:', error); |
| setStatus('error', 'Erro: ' + error.message); |
| } |
| } |
| |
| function connectWebSocket() { |
| const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| ws = new WebSocket(`${protocol}//${location.host}/ws/rtc`); |
| |
| ws.onopen = () => { |
| console.log('[WS] Connected'); |
| micBtn.disabled = false; |
| |
| |
| ws.send(JSON.stringify({ |
| type: 'session', |
| session_id: sessionId |
| })); |
| }; |
| |
| ws.onmessage = (event) => { |
| const data = JSON.parse(event.data); |
| handleMessage(data); |
| }; |
| |
| ws.onclose = () => { |
| console.log('[WS] Disconnected'); |
| micBtn.disabled = true; |
| setTimeout(connectWebSocket, 3000); |
| }; |
| |
| ws.onerror = (error) => { |
| console.error('[WS] Error:', error); |
| }; |
| } |
| |
| function handleMessage(data) { |
| switch (data.type) { |
| case 'transcription': |
| showResponse('Voce: ' + data.text, 'user'); |
| break; |
| case 'response': |
| showResponse(data.text, 'assistant'); |
| break; |
| case 'status': |
| setStatus('streaming', data.message); |
| break; |
| case 'audio': |
| playAudio(data.url); |
| break; |
| case 'info': |
| streamInfo.textContent = `${data.total_frames} frames @ ${data.fps}fps`; |
| break; |
| case 'done': |
| setStatus('connected', 'Pronto'); |
| break; |
| case 'error': |
| setStatus('error', data.message); |
| break; |
| } |
| } |
| |
| function setStatus(state, text) { |
| statusDot.className = 'status-dot ' + state; |
| statusText.textContent = text; |
| } |
| |
| function showResponse(text, role) { |
| responseText.textContent = text; |
| responseText.classList.remove('hidden'); |
| setTimeout(() => { |
| responseText.classList.add('hidden'); |
| }, 5000); |
| } |
| |
| async function playAudio(url) { |
| try { |
| const audio = new Audio(url); |
| await audio.play(); |
| } catch (e) { |
| console.error('[Audio] Error:', e); |
| } |
| } |
| |
| |
| micBtn.addEventListener('mousedown', startRecording); |
| micBtn.addEventListener('mouseup', stopRecording); |
| micBtn.addEventListener('mouseleave', stopRecording); |
| micBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); }); |
| micBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); }); |
| |
| async function startRecording() { |
| if (isRecording || !ws || ws.readyState !== WebSocket.OPEN) return; |
| |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); |
| 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]; |
| ws.send(JSON.stringify({ |
| type: 'start', |
| audio_base64: base64, |
| session_id: sessionId |
| })); |
| }; |
| reader.readAsDataURL(audioBlob); |
| stream.getTracks().forEach(track => track.stop()); |
| }; |
| |
| mediaRecorder.start(); |
| isRecording = true; |
| micBtn.classList.add('recording'); |
| setStatus('streaming', 'Gravando...'); |
| |
| } catch (error) { |
| console.error('[Recording] Error:', error); |
| setStatus('error', 'Erro no microfone'); |
| } |
| } |
| |
| function stopRecording() { |
| if (!isRecording || !mediaRecorder) return; |
| |
| mediaRecorder.stop(); |
| isRecording = false; |
| micBtn.classList.remove('recording'); |
| setStatus('streaming', 'Processando...'); |
| } |
| |
| |
| init(); |
| </script> |
| </body> |
| </html> |
|
|