Diffusers
MuseTalk1.5 / server /static /index_rtc.html
Marcos
Add complete MuseTalk real-time avatar system with WebSocket streaming
97ff05d
Raw
History Blame Contribute Delete
47.5 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 - 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>