dumont-talker / web /index_livekit.html
marcosremar2's picture
feat: add LiveKit WebRTC streaming support
dc36ee1
Raw
History Blame
13.1 kB
<!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;
// State
let room = null;
let sessionId = Math.random().toString(36).substring(2, 10);
let ws = null;
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
// Elements
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;
// Initialize
async function init() {
try {
// Check LiveKit config
const configRes = await fetch('/api/livekit/config');
const config = await configRes.json();
if (!config.enabled) {
setStatus('error', 'LiveKit nao disponivel');
return;
}
// Get token
const tokenRes = await fetch(`/api/livekit/token/${sessionId}`);
const tokenData = await tokenRes.json();
roomNameEl.textContent = tokenData.room_name;
// Connect to LiveKit room
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);
// Connect WebSocket for signaling
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;
// Send session info
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);
}
}
// Recording
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...');
}
// Start
init();
</script>
</body>
</html>