| <!DOCTYPE html> |
| <html lang="pt-BR"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"> |
| <title>AI Avatar Assistant</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| |
| body { |
| font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 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: 75vh; |
| border-radius: 32px; |
| overflow: hidden; |
| background: linear-gradient(145deg, #1a1a2e, #0f0f1a); |
| box-shadow: |
| 0 30px 60px rgba(0,0,0,0.5), |
| 0 0 0 1px rgba(255,255,255,0.05), |
| inset 0 1px 0 rgba(255,255,255,0.1); |
| } |
| |
| .avatar-section::before { |
| content: ''; |
| position: absolute; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| border-radius: 32px; |
| padding: 2px; |
| background: linear-gradient(145deg, rgba(255,255,255,0.1), transparent); |
| -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); |
| mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); |
| -webkit-mask-composite: xor; |
| mask-composite: exclude; |
| pointer-events: none; |
| z-index: 100; |
| } |
| |
| .avatar-section video, |
| .avatar-section canvas { |
| position: absolute; |
| top: 0; |
| left: 0; |
| width: 100%; |
| height: 100%; |
| object-fit: cover; |
| border-radius: 32px; |
| } |
| |
| #idle-video { |
| z-index: 5; |
| opacity: 1; |
| transition: opacity 0.1s ease-out; |
| } |
| |
| #idle-video.hidden { |
| opacity: 0; |
| pointer-events: none; |
| } |
| |
| #videoCanvas { |
| z-index: 10; |
| opacity: 0; |
| transition: opacity 0.1s ease-out; |
| } |
| |
| #videoCanvas.active { |
| opacity: 1; |
| } |
| |
| #avatar-video { |
| z-index: 15; |
| display: none; |
| } |
| |
| #avatar-video.active { |
| display: block; |
| } |
| |
| |
| .status-indicator { |
| position: absolute; |
| top: 16px; |
| left: 16px; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 8px 14px; |
| background: rgba(0,0,0,0.6); |
| backdrop-filter: blur(10px); |
| border-radius: 20px; |
| font-size: 0.75rem; |
| font-weight: 500; |
| z-index: 50; |
| } |
| |
| .status-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: #22c55e; |
| } |
| |
| .status-dot.recording { |
| background: #ef4444; |
| animation: pulse 0.5s ease-in-out infinite; |
| } |
| |
| .status-dot.processing { |
| background: #f59e0b; |
| animation: pulse 1s ease-in-out infinite; |
| } |
| |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; transform: scale(1); } |
| 50% { opacity: 0.5; transform: scale(1.2); } |
| } |
| |
| |
| .response-overlay { |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| right: 0; |
| padding: 60px 20px 20px; |
| background: linear-gradient(transparent, rgba(0,0,0,0.9)); |
| z-index: 40; |
| } |
| |
| .response-text { |
| font-size: 1rem; |
| line-height: 1.5; |
| color: #fff; |
| text-align: center; |
| min-height: 48px; |
| } |
| |
| |
| .loading-overlay { |
| display: none !important; |
| } |
| |
| |
| .controls { |
| display: flex; |
| gap: 12px; |
| margin-top: 20px; |
| } |
| |
| .record-btn { |
| flex: 1; |
| padding: 18px 24px; |
| border: none; |
| border-radius: 16px; |
| font-size: 1rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: all 0.2s; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 10px; |
| background: #fff; |
| color: #000; |
| } |
| |
| .record-btn:hover { |
| transform: scale(1.02); |
| box-shadow: 0 8px 30px rgba(255,255,255,0.2); |
| } |
| |
| .record-btn:active { |
| transform: scale(0.98); |
| } |
| |
| .record-btn.recording { |
| background: #ef4444; |
| color: #fff; |
| } |
| |
| .record-btn:disabled { |
| background: #333; |
| color: #666; |
| cursor: not-allowed; |
| transform: none; |
| box-shadow: none; |
| } |
| |
| .record-btn.vad-mode { |
| background: linear-gradient(145deg, #22c55e, #16a34a); |
| color: #fff; |
| animation: vad-pulse 2s ease-in-out infinite; |
| } |
| |
| .record-btn.vad-mode.recording { |
| background: linear-gradient(145deg, #ef4444, #dc2626); |
| animation: none; |
| } |
| |
| @keyframes vad-pulse { |
| 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); } |
| 50% { box-shadow: 0 0 0 15px rgba(34, 197, 94, 0); } |
| } |
| |
| .record-btn svg { |
| width: 20px; |
| height: 20px; |
| } |
| |
| .settings-btn { |
| padding: 18px; |
| border: none; |
| border-radius: 16px; |
| background: rgba(255,255,255,0.1); |
| color: #fff; |
| cursor: pointer; |
| transition: all 0.2s; |
| } |
| |
| .settings-btn:hover { |
| background: rgba(255,255,255,0.15); |
| } |
| |
| |
| .stats-bar { |
| display: flex; |
| justify-content: center; |
| gap: 20px; |
| margin-top: 16px; |
| font-size: 0.75rem; |
| color: rgba(255,255,255,0.5); |
| } |
| |
| .stat { |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| } |
| |
| .stat-value { |
| color: #fff; |
| font-weight: 600; |
| } |
| |
| |
| .modal { |
| display: none; |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| bottom: 0; |
| background: rgba(0,0,0,0.9); |
| backdrop-filter: blur(10px); |
| z-index: 100; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .modal.active { |
| display: flex; |
| } |
| |
| .modal-content { |
| background: #1a1a1f; |
| padding: 28px; |
| border-radius: 24px; |
| width: 90%; |
| max-width: 360px; |
| border: 1px solid rgba(255,255,255,0.1); |
| } |
| |
| .modal-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 24px; |
| } |
| |
| .modal-header h3 { |
| font-size: 1.2rem; |
| font-weight: 600; |
| } |
| |
| .modal-close-x { |
| background: none; |
| border: none; |
| color: #888; |
| cursor: pointer; |
| padding: 4px; |
| } |
| |
| .setting-group { |
| margin-bottom: 20px; |
| } |
| |
| .setting-group label { |
| display: block; |
| margin-bottom: 8px; |
| color: rgba(255,255,255,0.6); |
| font-size: 0.85rem; |
| } |
| |
| .setting-group select { |
| width: 100%; |
| padding: 14px 16px; |
| border-radius: 12px; |
| border: 1px solid rgba(255,255,255,0.1); |
| background: rgba(255,255,255,0.05); |
| color: #fff; |
| font-size: 1rem; |
| appearance: none; |
| cursor: pointer; |
| } |
| |
| .setting-group select option { |
| background: #1a1a1f; |
| } |
| |
| .modal-save { |
| width: 100%; |
| padding: 16px; |
| background: #fff; |
| color: #000; |
| border: none; |
| border-radius: 12px; |
| font-size: 1rem; |
| font-weight: 600; |
| cursor: pointer; |
| margin-top: 8px; |
| } |
| |
| .modal-save:hover { |
| background: #f0f0f0; |
| } |
| |
| |
| #audioPlayer { display: none; } |
| </style> |
| </head> |
| <body> |
| <div class="app-container"> |
| <div class="avatar-section" id="avatar-section"> |
| |
| <video id="idle-video" autoplay loop muted playsinline preload="auto" poster="static/avatar_thumb.jpg" src="avatar_videos/idle.mp4"></video> |
|
|
| |
| <canvas id="videoCanvas" width="1080" height="1920"></canvas> |
|
|
| |
| <video id="avatar-video" playsinline></video> |
|
|
| |
| <div class="status-indicator"> |
| <div class="status-dot" id="status-dot"></div> |
| <span id="status-text">Pronto</span> |
| </div> |
|
|
| |
| <div class="loading-overlay" id="loading-overlay"> |
| <div class="spinner"></div> |
| <div class="loading-text" id="loading-text">Processando...</div> |
| </div> |
|
|
| |
| <div class="response-overlay"> |
| <div class="response-text" id="response-text">Pressione o botao e fale comigo</div> |
| </div> |
| </div> |
|
|
| <div class="controls"> |
| <button class="record-btn" id="record-btn"> |
| <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> |
| <span id="btn-text">Falar</span> |
| </button> |
| <button class="settings-btn" id="settings-btn"> |
| <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <circle cx="12" cy="12" r="3"/> |
| <path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/> |
| </svg> |
| </button> |
| </div> |
|
|
| <div class="stats-bar" id="stats-bar"> |
| <div class="stat">Frames: <span class="stat-value" id="frame-count">0</span></div> |
| <div class="stat">FPS: <span class="stat-value" id="fps-display">0</span></div> |
| <div class="stat" onclick="toggleMetricsPanel()" style="cursor:pointer">📊</div> |
| </div> |
| <div id="metrics-panel" style="display:none; position:fixed; bottom:60px; left:10px; right:10px; background:rgba(0,0,0,0.9); padding:10px; border-radius:8px; font-size:11px; max-height:200px; overflow:auto; z-index:1000;"> |
| <div style="display:flex; justify-content:space-between; margin-bottom:5px;"> |
| <b>Metricas</b> |
| <span onclick="toggleMetricsPanel()" style="cursor:pointer">❌</span> |
| </div> |
| <div id="metrics-content">Aguardando...</div> |
| </div> |
| </div> |
|
|
| |
| <div class="modal" id="settings-modal"> |
| <div class="modal-content"> |
| <div class="modal-header"> |
| <h3>Configuracoes</h3> |
| <button class="modal-close-x" id="modal-close-x"> |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
| <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> |
| </svg> |
| </button> |
| </div> |
| <div class="setting-group"> |
| <label>Resolucao</label> |
| <select id="resolution-select"> |
| <option value="128">128px - Ultra rapido</option> |
| <option value="192">192px - Rapido</option> |
| <option value="256" selected>256px - Padrao</option> |
| </select> |
| </div> |
| <div class="setting-group"> |
| <label>Batch Size</label> |
| <select id="batch-select"> |
| <option value="8">8 - Menor latencia</option> |
| <option value="16" selected>16 - Balanceado</option> |
| <option value="32">32 - Mais rapido</option> |
| </select> |
| </div> |
| <div class="setting-group"> |
| <label>Modo de Entrada</label> |
| <select id="input-mode-select"> |
| <option value="push" selected>Push-to-Talk (Segurar botao)</option> |
| <option value="vad">VAD - Deteccao automatica de voz</option> |
| </select> |
| </div> |
| <button class="modal-save" id="modal-save">Salvar</button> |
| </div> |
| </div> |
|
|
| <audio id="audioPlayer" preload="none"></audio> |
|
|
| <script> |
| |
| const avatarSection = document.getElementById('avatar-section'); |
| const idleVideo = document.getElementById('idle-video'); |
| const videoCanvas = document.getElementById('videoCanvas'); |
| const ctx = videoCanvas.getContext('2d'); |
| const avatarVideo = document.getElementById('avatar-video'); |
| const statusDot = document.getElementById('status-dot'); |
| const statusText = document.getElementById('status-text'); |
| const loadingOverlay = document.getElementById('loading-overlay'); |
| const loadingText = document.getElementById('loading-text'); |
| const responseText = document.getElementById('response-text'); |
| const recordBtn = document.getElementById('record-btn'); |
| const btnText = document.getElementById('btn-text'); |
| const settingsBtn = document.getElementById('settings-btn'); |
| const settingsModal = document.getElementById('settings-modal'); |
| const modalCloseX = document.getElementById('modal-close-x'); |
| const modalSave = document.getElementById('modal-save'); |
| const resolutionSelect = document.getElementById('resolution-select'); |
| const batchSelect = document.getElementById('batch-select'); |
| const inputModeSelect = document.getElementById('input-mode-select'); |
| const frameCountEl = document.getElementById('frame-count'); |
| const fpsDisplay = document.getElementById('fps-display'); |
| const audioPlayer = document.getElementById('audioPlayer'); |
| |
| |
| let ws = null; |
| let mediaRecorder = null; |
| let audioChunks = []; |
| let isRecording = false; |
| let isPlaying = false; |
| let inputMode = 'push'; |
| |
| |
| let audioContext = null; |
| let analyser = null; |
| let vadStream = null; |
| let vadActive = false; |
| let silenceTimer = null; |
| const VAD_THRESHOLD = 15; |
| const SILENCE_DELAY = 1500; |
| |
| |
| let frameBuffer = []; |
| let decodedFrames = []; |
| let framesReceived = 0; |
| let totalFrames = 0; |
| let targetFps = 30; |
| let frameStartTime = 0; |
| let audioReady = false; |
| let audioLoaded = false; |
| let lastRenderedFrame = -1; |
| let idleTimeoutId = null; |
| const MIN_BUFFER_FRAMES = 5; |
| let firstFrameRendered = false; |
| |
| |
| class ClientMetrics { |
| constructor() { this.reset(); } |
| reset() { |
| this.startTime = Date.now(); |
| this.events = {}; |
| this.frameReceiveTimes = []; |
| this.frameRenderTimes = []; |
| this.drops = 0; |
| } |
| mark(event) { |
| this.events[event] = Date.now() - this.startTime; |
| console.log(`[METRICS] ${event}: ${this.events[event]}ms`); |
| } |
| frameReceived(index) { this.frameReceiveTimes.push({i: index, t: Date.now() - this.startTime}); } |
| frameRendered(index) { this.frameRenderTimes.push({i: index, t: Date.now() - this.startTime}); } |
| frameDrop() { this.drops++; } |
| getSummary() { |
| const avgGap = this.frameReceiveTimes.length > 1 ? (this.frameReceiveTimes[this.frameReceiveTimes.length-1].t - this.frameReceiveTimes[0].t) / (this.frameReceiveTimes.length - 1) : 0; |
| return { events: this.events, received: this.frameReceiveTimes.length, rendered: this.frameRenderTimes.length, drops: this.drops, avgGapMs: Math.round(avgGap), totalMs: Date.now() - this.startTime }; |
| } |
| getFullData() { |
| return { |
| events: this.events, |
| frameReceiveTimes: this.frameReceiveTimes, |
| frameRenderTimes: this.frameRenderTimes, |
| drops: this.drops, |
| totalMs: Date.now() - this.startTime |
| }; |
| } |
| } |
| const clientMetrics = new ClientMetrics(); |
| |
| |
| let metricsVisible = false; |
| let sessionLogs = []; |
| function toggleMetricsPanel() { |
| metricsVisible = !metricsVisible; |
| document.getElementById("metrics-panel").style.display = metricsVisible ? "block" : "none"; |
| } |
| function updateMetricsPanel(serverMetrics, clientSummary) { |
| const el = document.getElementById("metrics-content"); |
| if (!el) return; |
| 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>`; |
| } |
| } |
| html += `<br><b>Client (ms):</b><br>`; |
| if (clientSummary.events) { |
| for (const [k, v] of Object.entries(clientSummary.events)) { |
| html += `${k}: ${v}ms<br>`; |
| } |
| } |
| html += `<br><b>Stats:</b><br>`; |
| html += `Frames: ${clientSummary.received} recv<br>`; |
| html += `Drops: ${clientSummary.drops}<br>`; |
| html += `Avg gap: ${clientSummary.avgGapMs}ms<br>`; |
| html += `Total: ${clientSummary.totalMs}ms`; |
| el.innerHTML = html; |
| if (sessionLogs.length > 0) { |
| html += "<br><button onclick=\"exportLogs()\" style=\"margin-top:5px; padding:3px 8px;\">Exportar Logs</button>"; |
| el.innerHTML = html; |
| } |
| } |
| function exportLogs() { |
| const blob = new Blob([JSON.stringify(sessionLogs, null, 2)], {type: "application/json"}); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = "avatar_metrics_" + Date.now() + ".json"; |
| a.click(); |
| |
| } |
| |
| |
| function loadSettings() { |
| if (localStorage.getItem('resolution')) resolutionSelect.value = localStorage.getItem('resolution'); |
| if (localStorage.getItem('batch')) batchSelect.value = localStorage.getItem('batch'); |
| if (localStorage.getItem('inputMode')) { |
| inputModeSelect.value = localStorage.getItem('inputMode'); |
| inputMode = localStorage.getItem('inputMode'); |
| } |
| } |
| |
| function saveSettings() { |
| localStorage.setItem('resolution', resolutionSelect.value); |
| localStorage.setItem('batch', batchSelect.value); |
| localStorage.setItem('inputMode', inputModeSelect.value); |
| |
| |
| const newMode = inputModeSelect.value; |
| if (newMode !== inputMode) { |
| inputMode = newMode; |
| updateInputMode(); |
| } |
| |
| settingsModal.classList.remove('active'); |
| } |
| |
| |
| function updateInputMode() { |
| if (inputMode === 'vad') { |
| btnText.textContent = 'Escutando...'; |
| recordBtn.classList.add('vad-mode'); |
| startVAD(); |
| } else { |
| btnText.textContent = 'Falar'; |
| recordBtn.classList.remove('vad-mode'); |
| stopVAD(); |
| } |
| } |
| |
| async function startVAD() { |
| if (vadActive) return; |
| |
| try { |
| vadStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); |
| analyser = audioContext.createAnalyser(); |
| analyser.fftSize = 512; |
| |
| const source = audioContext.createMediaStreamSource(vadStream); |
| source.connect(analyser); |
| |
| vadActive = true; |
| monitorVAD(); |
| console.log('[VAD] Started'); |
| } catch (err) { |
| console.error('[VAD] Error:', err); |
| inputMode = 'push'; |
| inputModeSelect.value = 'push'; |
| btnText.textContent = 'Falar'; |
| } |
| } |
| |
| function stopVAD() { |
| vadActive = false; |
| if (silenceTimer) { |
| clearTimeout(silenceTimer); |
| silenceTimer = null; |
| } |
| if (vadStream) { |
| vadStream.getTracks().forEach(track => track.stop()); |
| vadStream = null; |
| } |
| if (audioContext) { |
| audioContext.close(); |
| audioContext = null; |
| } |
| console.log('[VAD] Stopped'); |
| } |
| |
| function monitorVAD() { |
| if (!vadActive || !analyser) return; |
| |
| const dataArray = new Uint8Array(analyser.frequencyBinCount); |
| analyser.getByteFrequencyData(dataArray); |
| |
| |
| const average = dataArray.reduce((a, b) => a + b) / dataArray.length; |
| |
| |
| if (average > VAD_THRESHOLD) { |
| |
| if (!isRecording && !isPlaying && recordBtn.disabled === false) { |
| console.log('[VAD] Voice detected, starting recording'); |
| startRecordingVAD(); |
| } |
| |
| if (silenceTimer) { |
| clearTimeout(silenceTimer); |
| silenceTimer = null; |
| } |
| } else if (isRecording && !silenceTimer) { |
| |
| silenceTimer = setTimeout(() => { |
| if (isRecording) { |
| console.log('[VAD] Silence detected, stopping recording'); |
| stopRecordingVAD(); |
| } |
| silenceTimer = null; |
| }, SILENCE_DELAY); |
| } |
| |
| |
| if (vadActive) { |
| requestAnimationFrame(monitorVAD); |
| } |
| } |
| |
| function startRecordingVAD() { |
| if (isRecording || isPlaying || recordBtn.disabled) return; |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| |
| isRecording = true; |
| audioChunks = []; |
| |
| |
| navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => { |
| mediaRecorder = new MediaRecorder(stream); |
| mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data); |
| mediaRecorder.onstop = () => { |
| stream.getTracks().forEach(track => track.stop()); |
| processAudio(); |
| }; |
| mediaRecorder.start(); |
| |
| recordBtn.classList.add('recording'); |
| btnText.textContent = 'Ouvindo...'; |
| setStatus('recording', 'Ouvindo...'); |
| }); |
| } |
| |
| function stopRecordingVAD() { |
| if (!isRecording || !mediaRecorder) return; |
| |
| isRecording = false; |
| if (mediaRecorder.state === 'recording') { |
| mediaRecorder.stop(); |
| } |
| recordBtn.classList.remove('recording'); |
| btnText.textContent = 'Escutando...'; |
| } |
| |
| |
| function startIdleVideo() { |
| if (isPlaying) return; |
| console.log('[IDLE] Starting idle video'); |
| idleVideo.classList.remove('hidden'); |
| videoCanvas.classList.remove('active'); |
| avatarVideo.classList.remove('active'); |
| idleVideo.play().catch(e => console.log('[IDLE] Autoplay blocked:', e)); |
| } |
| |
| function stopIdleVideo() { |
| console.log('[IDLE] Hiding idle video (crossfade)'); |
| idleVideo.classList.add('hidden'); |
| |
| } |
| |
| |
| function setStatus(status, text) { |
| statusDot.className = 'status-dot ' + status; |
| statusText.textContent = text; |
| } |
| |
| |
| function connectWebSocket() { |
| if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { |
| return; |
| } |
| |
| if (ws) { |
| ws.close(); |
| ws = null; |
| } |
| |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; |
| const basePath = window.location.pathname.replace(/\/$/, ''); |
| console.log('[WS] Connecting to:', `${protocol}//${window.location.host}${basePath}/ws/stream`); |
| ws = new WebSocket(`${protocol}//${window.location.host}${basePath}/ws/stream`); |
| |
| ws.onopen = () => { setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({type: "ping"})); }, 5000); |
| console.log('[WS] Connected'); |
| setStatus('', 'Pronto'); |
| }; |
| |
| ws.onclose = (e) => { |
| console.log('[WS] Disconnected, code:', e.code, 'reason:', e.reason, 'wasClean:', e.wasClean); |
| setTimeout(connectWebSocket, 2000); |
| }; |
| |
| ws.onerror = (e) => { |
| console.error('[WS] Error:', e); |
| }; |
| |
| ws.onmessage = async (event) => { if (event.data instanceof Blob) { const text = await event.data.text(); try { handleWSMessage(JSON.parse(text)); } catch(e) { console.log("[WS] Binary data received"); } } else { handleWSMessage(JSON.parse(event.data)); } }; |
| } |
| |
| function handleWSMessage(data) { |
| switch (data.type) { |
| case 'status': |
| loadingText.textContent = data.message; |
| break; |
| |
| case 'transcription': |
| clientMetrics.mark('transcription_received'); |
| responseText.textContent = '"' + data.text + '"'; |
| break; |
| |
| case 'response': |
| clientMetrics.mark('response_received'); |
| responseText.textContent = data.text; |
| break; |
| |
| case 'audio': |
| clientMetrics.mark('audio_url_received'); |
| console.log('[AUDIO] Received audio URL, framesReceived:', framesReceived); |
| audioReady = true; |
| audioLoaded = false; |
| const basePath = window.location.pathname.replace(/\/$/, ''); audioPlayer.src = basePath + data.url; |
| audioPlayer.load(); |
| |
| |
| audioPlayer.oncanplaythrough = () => { |
| if (!audioLoaded) { |
| audioLoaded = true; |
| clientMetrics.mark('audio_loaded'); |
| console.log('[AUDIO] Audio loaded and ready'); |
| tryStartPlayback(); |
| } |
| }; |
| |
| audioPlayer.onloadeddata = () => { |
| if (!audioLoaded) { |
| audioLoaded = true; |
| clientMetrics.mark('audio_loaded'); |
| console.log('[AUDIO] Audio data loaded'); |
| tryStartPlayback(); |
| } |
| }; |
| break; |
| |
| case 'info': |
| clientMetrics.mark('info_received'); |
| console.log('[STREAM] Info received:', data.total_frames, 'frames, audioLoaded:', audioLoaded); |
| |
| |
| if (idleTimeoutId) { |
| clearTimeout(idleTimeoutId); |
| idleTimeoutId = null; |
| } |
| |
| |
| totalFrames = data.total_frames; |
| targetFps = data.fps; |
| frameStartTime = Date.now(); |
| |
| |
| setStatus('processing', 'Gerando...'); |
| break; |
| |
| case 'frame': |
| frameBuffer[data.index] = data.frame; |
| clientMetrics.frameReceived(data.index); |
| framesReceived++; |
| frameCountEl.textContent = `${framesReceived}/${totalFrames}`; |
| |
| const elapsed = (Date.now() - frameStartTime) / 1000; |
| fpsDisplay.textContent = (framesReceived / elapsed).toFixed(1); |
| |
| |
| decodeFrame(data.index, data.frame); |
| |
| |
| if (framesReceived === 1) { |
| console.log('[FRAME] First frame received, pre-rendering to canvas'); |
| clientMetrics.mark('first_frame_received'); |
| renderFrameSync(data.frame); |
| firstFrameRendered = true; |
| } |
| |
| |
| if (framesReceived === MIN_BUFFER_FRAMES) { |
| console.log('[FRAME] Buffer threshold reached:', MIN_BUFFER_FRAMES, 'frames'); |
| tryStartPlayback(); |
| } |
| break; |
| |
| case 'done': |
| console.log('[METRICS] Server:', data.metrics); |
| console.log('[METRICS] Client:', clientMetrics.getSummary()); |
| updateMetricsPanel(data.metrics, clientMetrics.getSummary()); |
| sessionLogs.push({time: new Date().toISOString(), server: data.metrics, client: clientMetrics.getFullData()}); |
| console.log('[STREAM] All frames received:', framesReceived, 'audioLoaded:', audioLoaded, 'isPlaying:', isPlaying); |
| |
| if (!isPlaying && !playbackLock) { |
| tryStartPlayback(); |
| } |
| break; |
| |
| case 'error': |
| responseText.textContent = 'Erro: ' + data.message; |
| recordBtn.disabled = false; |
| setStatus('', 'Pronto'); |
| startIdleVideo(); |
| break; |
| } |
| } |
| |
| let playbackLock = false; |
| |
| |
| async function decodeFrame(index, base64Data) { |
| try { |
| const response = await fetch('data:image/jpeg;base64,' + base64Data); |
| const blob = await response.blob(); |
| const bitmap = await createImageBitmap(blob); |
| decodedFrames[index] = bitmap; console.log("[DECODE] Frame", index, "decoded"); |
| } catch (e) { |
| |
| console.log('[DECODE] Failed for frame', index); |
| } |
| } |
| |
| |
| function renderFrameSync(base64Data) { |
| const img = new Image(); |
| img.onload = () => { |
| ctx.clearRect(0, 0, videoCanvas.width, videoCanvas.height); |
| const scale = Math.max(videoCanvas.width / img.width, videoCanvas.height / img.height); |
| const x = (videoCanvas.width - img.width * scale) / 2; |
| const y = (videoCanvas.height - img.height * scale) / 2; |
| ctx.drawImage(img, x, y, img.width * scale, img.height * scale); |
| }; |
| img.src = 'data:image/jpeg;base64,' + base64Data; |
| } |
| |
| |
| function tryStartPlayback() { |
| if (isPlaying || playbackLock) { |
| console.log('[TRY] Blocked - already playing or locked'); |
| return; |
| } |
| |
| |
| if (!audioLoaded) { |
| console.log('[TRY] Waiting for audio to load...'); |
| return; |
| } |
| |
| if (framesReceived < MIN_BUFFER_FRAMES && framesReceived < totalFrames) { |
| console.log('[TRY] Waiting for more frames:', framesReceived, '/', MIN_BUFFER_FRAMES); |
| return; |
| } |
| |
| if (framesReceived === 0) { |
| console.log('[TRY] No frames received yet'); |
| return; |
| } |
| |
| console.log('[TRY] All conditions met, starting playback'); |
| startSyncPlayback(); |
| } |
| |
| function startSyncPlayback() { |
| |
| if (isPlaying || playbackLock) { |
| console.log('[PLAYBACK] Blocked - already playing'); |
| return; |
| } |
| |
| playbackLock = true; |
| isPlaying = true; |
| console.log('[PLAYBACK] Starting'); |
| clientMetrics.mark('playback_start'); |
| |
| |
| if (idleTimeoutId) { |
| clearTimeout(idleTimeoutId); |
| idleTimeoutId = null; |
| } |
| |
| |
| stopIdleVideo(); |
| videoCanvas.classList.add('active'); |
| setStatus('', 'Falando...'); |
| |
| |
| audioPlayer.currentTime = 0; |
| audioPlayer.play().catch(e => console.log('[AUDIO] Play error:', e)); |
| |
| |
| syncPlaybackLoop(); |
| } |
| |
| function syncPlaybackLoop() { |
| if (!isPlaying) return; |
| |
| |
| if (audioPlayer.ended) { |
| console.log('[PLAYBACK] Audio ended'); |
| stopSyncPlayback(); |
| return; |
| } |
| |
| const audioTime = audioPlayer.currentTime; |
| let frameIndex = Math.floor(audioTime * targetFps); |
| frameIndex = Math.max(0, Math.min(frameIndex, totalFrames - 1)); |
| |
| |
| let targetIndex = frameIndex; |
| |
| |
| let decodedFrame = decodedFrames[frameIndex]; |
| if (!decodedFrame) { |
| |
| for (let i = frameIndex - 1; i >= 0; i--) { |
| if (decodedFrames[i]) { |
| decodedFrame = decodedFrames[i]; |
| targetIndex = i; |
| break; |
| } |
| } |
| } |
| |
| if (decodedFrame && targetIndex !== lastRenderedFrame) { |
| |
| console.log("[RENDER] Rendering decoded frame", targetIndex); renderDecodedFrame(decodedFrame); clientMetrics.frameRendered(targetIndex); |
| lastRenderedFrame = targetIndex; |
| } else if (!decodedFrame && frameBuffer[frameIndex] && frameIndex !== lastRenderedFrame) { |
| |
| console.log("[RENDER] Rendering base64 frame", frameIndex); renderFrameSync(frameBuffer[frameIndex]); clientMetrics.frameRendered(frameIndex); |
| lastRenderedFrame = frameIndex; |
| } |
| |
| frameCountEl.textContent = `${frameIndex + 1}/${totalFrames}`; |
| |
| requestAnimationFrame(syncPlaybackLoop); |
| } |
| |
| function stopSyncPlayback() { |
| console.log('[PLAYBACK] Stopping'); |
| isPlaying = false; |
| |
| |
| audioPlayer.pause(); |
| videoCanvas.classList.remove('active'); |
| recordBtn.disabled = false; |
| |
| |
| if (inputMode === 'vad') { |
| setStatus('', 'Escutando...'); |
| btnText.textContent = 'Escutando...'; |
| } else { |
| setStatus('', 'Pronto'); |
| } |
| |
| |
| if (idleTimeoutId) clearTimeout(idleTimeoutId); |
| idleTimeoutId = setTimeout(() => { |
| if (!isPlaying && !isRecording) { |
| startIdleVideo(); |
| } |
| }, 300); |
| } |
| |
| |
| function renderDecodedFrame(bitmap) { |
| ctx.clearRect(0, 0, videoCanvas.width, videoCanvas.height); |
| const scale = Math.max(videoCanvas.width / bitmap.width, videoCanvas.height / bitmap.height); |
| const x = (videoCanvas.width - bitmap.width * scale) / 2; |
| const y = (videoCanvas.height - bitmap.height * scale) / 2; |
| ctx.drawImage(bitmap, x, y, bitmap.width * scale, bitmap.height * scale); |
| } |
| |
| |
| async function initMicrophone() { |
| try { |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| mediaRecorder = new MediaRecorder(stream); |
| mediaRecorder.ondataavailable = (e) => audioChunks.push(e.data); |
| mediaRecorder.onstop = () => processAudio(); |
| } catch (err) { |
| responseText.textContent = 'Erro: Permita o microfone'; |
| recordBtn.disabled = true; |
| } |
| } |
| |
| function startRecording() { |
| |
| if (inputMode !== 'push') return; |
| clientMetrics.reset(); |
| if (isRecording || !mediaRecorder || recordBtn.disabled) return; |
| if (!ws || ws.readyState !== WebSocket.OPEN) { |
| responseText.textContent = 'Conectando...'; |
| connectWebSocket(); |
| return; |
| } |
| |
| isRecording = true; |
| audioChunks = []; |
| mediaRecorder.start(); |
| |
| |
| startIdleVideo(); |
| |
| recordBtn.classList.add('recording'); |
| btnText.textContent = 'Solte para enviar'; |
| setStatus('recording', 'Ouvindo...'); |
| videoCanvas.classList.remove('active'); |
| avatarVideo.classList.remove('active'); |
| } |
| |
| function stopRecording() { |
| |
| if (inputMode !== 'push') return; |
| clientMetrics.reset(); |
| if (!isRecording) return; |
| isRecording = false; |
| mediaRecorder.stop(); |
| recordBtn.classList.remove('recording'); |
| btnText.textContent = 'Falar'; |
| } |
| |
| async function processAudio() { |
| const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
| audioChunks = []; |
| |
| setStatus('processing', 'Processando...'); |
| recordBtn.disabled = true; |
| |
| |
| audioReady = false; |
| audioLoaded = false; |
| framesReceived = 0; |
| frameBuffer = []; |
| decodedFrames = []; |
| firstFrameRendered = false; |
| lastRenderedFrame = -1; |
| isPlaying = false; |
| playbackLock = false; |
| |
| |
| audioPlayer.oncanplaythrough = null; |
| audioPlayer.onloadeddata = null; |
| |
| console.log('[PROCESS] Starting new request, state reset'); |
| |
| |
| startIdleVideo(); |
| |
| if (!ws || ws.readyState !== WebSocket.OPEN) { |
| responseText.textContent = 'Erro: Sem conexao'; |
| recordBtn.disabled = false; |
| return; |
| } |
| |
| const reader = new FileReader(); |
| reader.onloadend = () => { |
| ws.send(JSON.stringify({ |
| type: 'start', |
| audio_base64: reader.result.split(',')[1], |
| resolution: parseInt(resolutionSelect.value), |
| batch_size: parseInt(batchSelect.value) |
| })); |
| }; |
| reader.readAsDataURL(audioBlob); |
| } |
| |
| |
| recordBtn.addEventListener('mousedown', startRecording); |
| recordBtn.addEventListener('mouseup', stopRecording); |
| recordBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); }); |
| recordBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startRecording(); }); |
| recordBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopRecording(); }); |
| |
| settingsBtn.onclick = () => settingsModal.classList.add('active'); |
| modalCloseX.onclick = () => settingsModal.classList.remove('active'); |
| modalSave.onclick = saveSettings; |
| settingsModal.onclick = (e) => { if (e.target === settingsModal) settingsModal.classList.remove('active'); }; |
| |
| |
| idleVideo.onclick = () => { |
| idleVideo.play().catch(e => console.log('[IDLE] Click play error:', e)); |
| }; |
| |
| |
| window.addEventListener('beforeunload', () => { |
| if (ws) { |
| ws.close(); |
| ws = null; |
| } |
| }); |
| |
| |
| loadSettings(); |
| initMicrophone(); |
| connectWebSocket(); |
| updateInputMode(); |
| |
| |
| idleVideo.style.display = 'block'; |
| |
| |
| idleVideo.oncanplaythrough = () => { console.log('[IDLE] Video fully buffered'); }; idleVideo.onloadeddata = () => { |
| console.log('[IDLE] Video data loaded, attempting play'); |
| idleVideo.play().catch(e => { |
| console.log('[IDLE] Autoplay blocked:', e); |
| responseText.textContent = 'Toque para ativar'; |
| }); |
| }; |
| |
| |
| idleVideo.play().then(() => { |
| console.log('[IDLE] Video playing'); |
| }).catch(e => { |
| console.log('[IDLE] Initial autoplay blocked'); |
| }); |
| |
| |
| ['click', 'touchstart'].forEach(event => { |
| document.addEventListener(event, () => { |
| if (idleVideo.paused && !isPlaying) { |
| idleVideo.play().then(() => { |
| responseText.textContent = 'Pressione o botao e fale comigo'; |
| }).catch(() => {}); |
| } |
| }, { once: true }); |
| }); |
| </script> |
| </body> |
| </html> |
|
|