| <!DOCTYPE html> |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| <html lang="pt-BR"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Avatar - Optimized</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%); |
| color: #fff; |
| min-height: 100vh; |
| padding: 20px; |
| } |
| .container { max-width: 1200px; margin: 0 auto; } |
| h1 { text-align: center; margin-bottom: 20px; color: #00ff88; } |
| |
| .status-bar { |
| display: flex; gap: 15px; justify-content: center; margin-bottom: 20px; |
| flex-wrap: wrap; |
| } |
| .status-item { |
| padding: 8px 16px; border-radius: 20px; |
| background: rgba(255,255,255,0.1); |
| font-size: 13px; |
| } |
| .status-item.online { background: rgba(0,255,100,0.2); color: #0f0; } |
| .status-item.offline { background: rgba(255,0,0,0.2); color: #f00; } |
| .status-item.streaming { background: rgba(0,255,136,0.2); color: #0f8; } |
| |
| .main-content { display: flex; gap: 20px; flex-wrap: wrap; } |
| |
| .video-section { |
| flex: 1.5; min-width: 450px; |
| background: rgba(0,0,0,0.3); border-radius: 15px; padding: 20px; |
| } |
| .canvas-container { |
| width: 100%; aspect-ratio: 16/9; |
| background: #000; border-radius: 10px; overflow: hidden; |
| position: relative; |
| } |
| |
| #idle-video { |
| position: absolute; top: 0; left: 0; |
| width: 1px; height: 1px; |
| opacity: 0; |
| pointer-events: none; |
| } |
| |
| #avatar-canvas { |
| position: absolute; top: 0; left: 0; |
| width: 100%; height: 100%; |
| object-fit: contain; |
| display: block; |
| } |
| |
| .control-section { |
| flex: 1; min-width: 280px; |
| background: rgba(0,0,0,0.3); border-radius: 15px; padding: 20px; |
| } |
| |
| .input-group { margin-bottom: 15px; } |
| label { display: block; margin-bottom: 5px; color: #aaa; font-size: 13px; } |
| textarea { |
| width: 100%; height: 80px; padding: 10px; |
| border: 1px solid #333; border-radius: 8px; |
| background: rgba(255,255,255,0.05); color: #fff; |
| resize: vertical; font-size: 14px; |
| } |
| select, button { |
| width: 100%; padding: 12px; margin-top: 8px; |
| border: none; border-radius: 8px; cursor: pointer; |
| font-size: 14px; |
| } |
| select { background: rgba(255,255,255,0.1); color: #fff; } |
| |
| .btn-primary { |
| background: linear-gradient(135deg, #00ff88, #00aa55); |
| color: #000; font-weight: bold; |
| } |
| .btn-danger { |
| background: linear-gradient(135deg, #ff4444, #cc0000); |
| color: #fff; font-weight: bold; |
| } |
| button:hover { opacity: 0.9; } |
| button:disabled { opacity: 0.5; cursor: not-allowed; } |
| |
| .metrics { |
| margin-top: 15px; padding: 12px; |
| background: rgba(0,255,136,0.05); border-radius: 8px; |
| border: 1px solid rgba(0,255,136,0.2); |
| } |
| .metrics h4 { margin-bottom: 8px; color: #0f8; font-size: 13px; } |
| .metric-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; } |
| .metric-value { color: #0f8; font-weight: bold; font-family: monospace; } |
| |
| .log { |
| margin-top: 15px; padding: 8px; |
| background: #000; border-radius: 8px; |
| font-family: monospace; font-size: 10px; |
| max-height: 100px; overflow-y: auto; |
| } |
| .log-entry { padding: 2px 0; border-bottom: 1px solid #222; } |
| .log-time { color: #666; } |
| .log-msg { color: #0f8; } |
| .log-error { color: #f00; } |
| .log-status { color: #fc0; } |
| </style> |
| </head> |
| <body> |
| <div class="container"> |
| <h1>Avatar - Optimized Binary</h1> |
|
|
| <div class="status-bar"> |
| <div class="status-item" id="ws-status">WS: --</div> |
| <div class="status-item" id="stream-status">Stream: Idle</div> |
| <div class="status-item" id="fps-status">FPS: --</div> |
| <div class="status-item" id="bandwidth-status">BW: --</div> |
| </div> |
|
|
| <div class="main-content"> |
| <div class="video-section"> |
| <h3 style="margin-bottom:10px">Avatar Stream (Binary Mode)</h3> |
| <div class="canvas-container"> |
| <video id="idle-video" loop muted playsinline autoplay preload="auto" src="idle.mp4"></video> |
| <canvas id="avatar-canvas"></canvas> |
| </div> |
| </div> |
|
|
| <div class="control-section"> |
| <h3 style="margin-bottom:10px">Controles</h3> |
|
|
| <div class="input-group"> |
| <label>Texto:</label> |
| <textarea id="text-input">Hello! I am a real-time streaming avatar optimized for low latency.</textarea> |
| </div> |
|
|
| <div class="input-group"> |
| <label>Voz:</label> |
| <select id="voice-select"> |
| <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> |
| </div> |
|
|
| <div class="input-group" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid rgba(255,255,255,0.1);"> |
| <label>Qualidade Video Fala: <span id="quality-value">95</span>%</label> |
| <input type="range" id="quality-slider" min="50" max="100" value="95" |
| style="width: 100%; margin-top: 5px;" |
| oninput="document.getElementById('quality-value').textContent = this.value"> |
| <small style="color: #666; font-size: 11px;">Aumentar para igualar com vídeo idle</small> |
| </div> |
|
|
| <div class="input-group"> |
| <label>Offset Transição: <span id="offset-value">0</span>ms</label> |
| <input type="range" id="offset-slider" min="-500" max="500" value="0" |
| style="width: 100%; margin-top: 5px;" |
| oninput="document.getElementById('offset-value').textContent = this.value"> |
| <small style="color: #666; font-size: 11px;">Ajuste fino do momento de voltar ao idle</small> |
| </div> |
|
|
| <button id="generate-btn" class="btn-primary" onclick="generate()" style="margin-top: 15px;"> |
| Gerar |
| </button> |
| <button id="stop-btn" class="btn-danger" onclick="stop()" disabled> |
| Parar |
| </button> |
|
|
| <div class="input-group" style="margin-top: 15px; padding: 12px; background: rgba(255,200,0,0.1); border-radius: 8px; border: 1px solid rgba(255,200,0,0.3);"> |
| <label style="color: #fc0; font-weight: bold;">Modo Demo</label> |
| <div style="display: flex; gap: 10px; margin-top: 8px; align-items: center;"> |
| <button id="demo-btn" class="btn-primary" onclick="toggleDemo()" style="flex: 1; background: linear-gradient(135deg, #fc0, #f90); margin: 0;"> |
| Iniciar Demo |
| </button> |
| </div> |
| <div style="display: flex; gap: 10px; margin-top: 10px;"> |
| <div style="flex: 1;"> |
| <label style="font-size: 11px;">Idle: <span id="demo-idle-value">5</span>s</label> |
| <input type="range" id="demo-idle-slider" min="1" max="30" value="5" |
| style="width: 100%;" |
| oninput="document.getElementById('demo-idle-value').textContent = this.value"> |
| </div> |
| </div> |
| <small style="color: #666; font-size: 11px;">Alterna entre falar e idle automaticamente</small> |
| </div> |
|
|
| <div class="metrics"> |
| <h4>Metricas (Binary)</h4> |
| <div class="metric-row"> |
| <span>Latencia (ate playback):</span> |
| <span class="metric-value" id="latency">--</span> |
| </div> |
| <div class="metric-row"> |
| <span>Frames recebidos:</span> |
| <span class="metric-value" id="frames">0</span> |
| </div> |
| <div class="metric-row"> |
| <span>Frames renderizados:</span> |
| <span class="metric-value" id="rendered">0</span> |
| </div> |
| <div class="metric-row"> |
| <span>Buffer:</span> |
| <span class="metric-value" id="buffer">0</span> |
| </div> |
| <div class="metric-row"> |
| <span>Bytes recebidos:</span> |
| <span class="metric-value" id="bytes">0 KB</span> |
| </div> |
| <div class="metric-row" style="border-top: 1px solid rgba(0,255,136,0.3); padding-top: 8px; margin-top: 4px;"> |
| <span>Duracao Video:</span> |
| <span class="metric-value" id="video-duration">--</span> |
| </div> |
| <div class="metric-row"> |
| <span>Duracao Audio:</span> |
| <span class="metric-value" id="audio-duration">--</span> |
| </div> |
| <div class="metric-row"> |
| <span>Diferenca (A-V):</span> |
| <span class="metric-value" id="sync-diff">--</span> |
| </div> |
| </div> |
|
|
| <div class="log" id="log"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| const WS_URL = "ws://localhost:8080/ws"; |
| const TARGET_FPS = 25; |
| const FRAME_INTERVAL = 1000 / TARGET_FPS; |
| |
| |
| const MSG_FRAME = 0x01; |
| const MSG_AUDIO = 0x02; |
| const MSG_AUDIO_CHUNK = 0x03; |
| |
| let ws = null; |
| let isStreaming = false; |
| let startTime = null; |
| |
| |
| let frameQueue = []; |
| let allFrames = []; |
| let renderedFrames = 0; |
| let renderInterval = null; |
| let lastRenderTime = 0; |
| let totalBytes = 0; |
| |
| |
| let audioContext = null; |
| let audioSource = null; |
| let audioBuffer = null; |
| let audioDuration = 0; |
| let audioChunks = []; |
| let audioChunksComplete = false; |
| let streamDone = false; |
| let syncedFrameInterval = FRAME_INTERVAL; |
| let playbackStarted = false; |
| |
| |
| |
| const MIN_FRAMES_TO_START = 5; |
| const MIN_AUDIO_CHUNKS_TO_START = 2; |
| const PROGRESSIVE_MODE = true; |
| |
| |
| let nextAudioTime = 0; |
| let audioScheduledChunks = 0; |
| let firstChunkTime = null; |
| let totalAudioSamples = 0; |
| let currentAudioSource = null; |
| let audioPlaybackStartTime = 0; |
| let audioExpectedEndTime = 0; |
| let progressivePlaybackStarted = false; |
| let audioContextStartTime = 0; |
| |
| |
| let endVideoTimeMs = null; |
| let startFrameIdx = null; |
| let endFrameIdx = null; |
| let waitingForFrameSync = false; |
| let frameSyncStartTime = 0; |
| const FRAME_SYNC_TIMEOUT = 500; |
| let idleVideoDurationMs = 0; |
| let idleVideoTotalFrames = 0; |
| |
| |
| let renderSource = 'idle'; |
| let unifiedRenderLoop = null; |
| |
| |
| let networkLatencyMs = 100; |
| let lastPingSentAt = null; |
| let latencyHistory = []; |
| const LATENCY_SAMPLES = 5; |
| const IDLE_VIDEO_FPS = 25; |
| |
| |
| const canvas = document.getElementById("avatar-canvas"); |
| const ctx = canvas.getContext("2d"); |
| const idleVideo = document.getElementById("idle-video"); |
| |
| |
| idleVideo.play().catch(e => console.log("Autoplay blocked:", e)); |
| |
| |
| |
| |
| let speakingFrameIndex = 0; |
| let lastSpeakingRenderTime = 0; |
| |
| |
| |
| function startUnifiedRenderLoop() { |
| if (unifiedRenderLoop) return; |
| |
| function renderFrame() { |
| |
| if (waitingForFrameSync && startFrameIdx !== null) { |
| |
| if (idleVideo.readyState >= 2) { |
| ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height); |
| } |
| |
| |
| const waitingTime = performance.now() - frameSyncStartTime; |
| if (waitingTime > FRAME_SYNC_TIMEOUT) { |
| console.log(`Frame sync: TIMEOUT após ${waitingTime.toFixed(0)}ms - iniciando sem sync`); |
| waitingForFrameSync = false; |
| doStartSpeaking(); |
| unifiedRenderLoop = requestAnimationFrame(renderFrame); |
| return; |
| } |
| |
| |
| const currentIdleFrame = Math.floor(idleVideo.currentTime * IDLE_VIDEO_FPS) % idleVideoTotalFrames; |
| |
| |
| const frameDiff = Math.abs(currentIdleFrame - startFrameIdx); |
| const isCloseEnough = frameDiff <= 2 || frameDiff >= (idleVideoTotalFrames - 2); |
| |
| if (isCloseEnough) { |
| console.log(`Frame sync: frame atual=${currentIdleFrame}, alvo=${startFrameIdx} - INICIANDO! (${waitingTime.toFixed(0)}ms)`); |
| waitingForFrameSync = false; |
| doStartSpeaking(); |
| } |
| |
| unifiedRenderLoop = requestAnimationFrame(renderFrame); |
| return; |
| } |
| |
| if (renderSource === 'idle') { |
| |
| |
| if (idleVideo.readyState >= 2) { |
| ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height); |
| } else { |
| |
| console.log(`[IDLE] Video não pronto: readyState=${idleVideo.readyState}, tentando play...`); |
| idleVideo.play().catch(e => {}); |
| } |
| } else if (renderSource === 'speaking') { |
| |
| const now = performance.now(); |
| const elapsed = now - lastSpeakingRenderTime; |
| |
| |
| |
| |
| if (Math.floor(now / 1000) !== Math.floor(lastSpeakingRenderTime / 1000)) { |
| console.log(`[DEBUG] streamDone=${streamDone}, queue=${frameQueue.length}, rendered=${renderedFrames}, audioEnd=${audioExpectedEndTime.toFixed(0)}, now=${now.toFixed(0)}`); |
| } |
| |
| |
| const transitionOffset = parseInt(document.getElementById("offset-slider").value) || 0; |
| const adjustedEndTime = audioExpectedEndTime + transitionOffset; |
| if (audioExpectedEndTime > 0 && now >= adjustedEndTime) { |
| console.log(`[FIM] Audio terminou: now=${now.toFixed(0)} >= end=${adjustedEndTime.toFixed(0)} (offset=${transitionOffset})`); |
| finishPlayback(); |
| |
| } |
| |
| |
| else if (streamDone && frameQueue.length === 0) { |
| console.log(`[FIM] Stream done + fila vazia: rendered=${renderedFrames}, total=${allFrames.length}`); |
| finishPlayback(); |
| |
| } |
| |
| |
| if (elapsed >= syncedFrameInterval && frameQueue.length > 0) { |
| const frameData = frameQueue.shift(); |
| |
| |
| const img = new Image(); |
| img.onload = () => { |
| |
| if (canvas.width !== img.width || canvas.height !== img.height) { |
| canvas.width = img.width; |
| canvas.height = img.height; |
| } |
| ctx.drawImage(img, 0, 0, canvas.width, canvas.height); |
| URL.revokeObjectURL(frameData.url); |
| }; |
| img.src = frameData.url; |
| |
| renderedFrames++; |
| document.getElementById("rendered").textContent = renderedFrames; |
| document.getElementById("buffer").textContent = frameQueue.length; |
| lastSpeakingRenderTime = now; |
| |
| |
| const fps = 1000 / elapsed; |
| updateStatus("fps-status", "", `FPS: ${fps.toFixed(0)}`); |
| |
| |
| const totalElapsed = (Date.now() - startTime) / 1000; |
| const bw = totalElapsed > 0 ? (totalBytes / 1024 / totalElapsed) : 0; |
| updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`); |
| |
| } |
| } |
| |
| unifiedRenderLoop = requestAnimationFrame(renderFrame); |
| } |
| |
| unifiedRenderLoop = requestAnimationFrame(renderFrame); |
| console.log("Unified render loop started"); |
| } |
| |
| function stopUnifiedRenderLoop() { |
| if (unifiedRenderLoop) { |
| cancelAnimationFrame(unifiedRenderLoop); |
| unifiedRenderLoop = null; |
| console.log("Unified render loop stopped"); |
| } |
| } |
| |
| |
| idleVideo.addEventListener('loadeddata', () => { |
| |
| canvas.width = idleVideo.videoWidth || 512; |
| canvas.height = idleVideo.videoHeight || 512; |
| |
| |
| idleVideoDurationMs = (idleVideo.duration || 60) * 1000; |
| idleVideoTotalFrames = Math.round((idleVideo.duration || 60) * IDLE_VIDEO_FPS); |
| console.log(`Video idle carregado: ${canvas.width}x${canvas.height}, ${idleVideo.duration?.toFixed(1)}s, ~${idleVideoTotalFrames} frames`); |
| |
| |
| idleVideo.loop = true; |
| idleVideo.muted = true; |
| idleVideo.play().then(() => { |
| console.log("Video idle iniciado com sucesso"); |
| startUnifiedRenderLoop(); |
| }).catch(e => { |
| console.log("Autoplay bloqueado, iniciando render loop mesmo assim:", e); |
| startUnifiedRenderLoop(); |
| }); |
| }); |
| |
| |
| setTimeout(() => { |
| if (!unifiedRenderLoop) { |
| console.log("Fallback: iniciando render loop após timeout"); |
| canvas.width = 512; |
| canvas.height = 512; |
| startUnifiedRenderLoop(); |
| } |
| }, 3000); |
| |
| |
| idleVideo.addEventListener('error', (e) => { |
| console.error("Erro carregando vídeo idle:", e); |
| log("Erro carregando vídeo idle", "error"); |
| |
| setTimeout(() => { |
| console.log("Tentando recarregar vídeo idle..."); |
| idleVideo.load(); |
| }, 2000); |
| }); |
| |
| |
| idleVideo.addEventListener('ended', () => { |
| console.log("Video idle ended - reiniciando"); |
| idleVideo.currentTime = 0; |
| idleVideo.play().catch(e => console.log("Erro reiniciando idle:", e)); |
| }); |
| |
| |
| idleVideo.addEventListener('pause', () => { |
| if (renderSource === 'idle') { |
| console.log("Video idle pausou inesperadamente - retomando"); |
| idleVideo.play().catch(e => console.log("Erro retomando idle:", e)); |
| } |
| }); |
| |
| |
| idleVideo.src = `idle.mp4?t=${Date.now()}`; |
| |
| function log(msg, type = "msg") { |
| const logDiv = document.getElementById("log"); |
| const time = new Date().toLocaleTimeString(); |
| logDiv.innerHTML = `<div class="log-entry"><span class="log-time">${time}</span> <span class="log-${type}">${msg}</span></div>` + logDiv.innerHTML; |
| while (logDiv.children.length > 20) logDiv.removeChild(logDiv.lastChild); |
| } |
| |
| function updateStatus(id, status, text) { |
| const el = document.getElementById(id); |
| el.textContent = text; |
| el.className = "status-item " + status; |
| } |
| |
| function connectWebSocket() { |
| if (ws && ws.readyState === WebSocket.OPEN) return; |
| |
| updateStatus("ws-status", "", "WS: Conectando..."); |
| ws = new WebSocket(WS_URL); |
| ws.binaryType = "arraybuffer"; |
| |
| ws.onopen = () => { |
| updateStatus("ws-status", "online", "WS: Conectado"); |
| log("Conectado (binary mode)", "msg"); |
| |
| measureLatency(); |
| }; |
| |
| ws.onmessage = (event) => { |
| if (event.data instanceof ArrayBuffer) { |
| |
| handleBinaryMessage(event.data); |
| } else { |
| |
| try { |
| const data = JSON.parse(event.data); |
| handleJsonMessage(data); |
| } catch (e) { |
| log("Erro JSON: " + e, "error"); |
| } |
| } |
| }; |
| |
| ws.onclose = () => { |
| updateStatus("ws-status", "offline", "WS: Desconectado"); |
| setTimeout(connectWebSocket, 3000); |
| }; |
| |
| ws.onerror = () => log("Erro WebSocket", "error"); |
| } |
| |
| |
| function measureLatency() { |
| if (!ws || ws.readyState !== WebSocket.OPEN) return; |
| |
| lastPingSentAt = performance.now(); |
| ws.send(JSON.stringify({ action: "ping", timestamp: lastPingSentAt })); |
| } |
| |
| |
| function handlePong(serverTimestamp) { |
| if (!lastPingSentAt) return; |
| |
| const rtt = performance.now() - lastPingSentAt; |
| const oneWayLatency = rtt / 2; |
| |
| |
| latencyHistory.push(oneWayLatency); |
| if (latencyHistory.length > LATENCY_SAMPLES) { |
| latencyHistory.shift(); |
| } |
| |
| |
| networkLatencyMs = latencyHistory.reduce((a, b) => a + b, 0) / latencyHistory.length; |
| |
| console.log(`Latência: RTT=${rtt.toFixed(0)}ms, one-way=${networkLatencyMs.toFixed(0)}ms`); |
| |
| |
| setTimeout(measureLatency, 5000); |
| } |
| |
| |
| function getCompensatedIdleVideoTime() { |
| const currentTimeMs = idleVideo.currentTime * 1000; |
| const videoDurationMs = (idleVideo.duration || 60) * 1000; |
| |
| |
| |
| |
| const processingEstimateMs = 50; |
| const compensatedTime = currentTimeMs + networkLatencyMs + processingEstimateMs; |
| |
| |
| return Math.round(compensatedTime % videoDurationMs); |
| } |
| |
| function handleBinaryMessage(buffer) { |
| const view = new DataView(buffer); |
| const msgType = view.getUint8(0); |
| |
| if (msgType === MSG_FRAME) { |
| |
| const frameIndex = view.getUint32(1, true); |
| const dataSize = view.getUint32(5, true); |
| const frameData = new Uint8Array(buffer, 9, dataSize); |
| |
| totalBytes += buffer.byteLength; |
| document.getElementById("bytes").textContent = (totalBytes / 1024).toFixed(1) + " KB"; |
| |
| handleFrame(frameData, frameIndex); |
| |
| } else if (msgType === MSG_AUDIO) { |
| |
| const sampleRate = view.getUint32(1, true); |
| const dataSize = view.getUint32(5, true); |
| const audioData = new Uint8Array(buffer, 9, dataSize); |
| |
| totalBytes += buffer.byteLength; |
| document.getElementById("bytes").textContent = (totalBytes / 1024).toFixed(1) + " KB"; |
| |
| handleAudioComplete(audioData, sampleRate); |
| |
| } else if (msgType === MSG_AUDIO_CHUNK) { |
| |
| const chunkIndex = view.getUint32(1, true); |
| const isLast = view.getUint8(5) === 1; |
| const dataSize = view.getUint32(6, true); |
| const chunkData = new Uint8Array(buffer, 10, dataSize); |
| |
| totalBytes += buffer.byteLength; |
| document.getElementById("bytes").textContent = (totalBytes / 1024).toFixed(1) + " KB"; |
| |
| handleAudioChunk(chunkData, chunkIndex, isLast); |
| } |
| } |
| |
| function handleJsonMessage(data) { |
| switch (data.type) { |
| case "pong": |
| |
| handlePong(data.timestamp); |
| break; |
| |
| case "first_frame": |
| |
| if (data.start_frame_idx !== undefined && data.start_frame_idx !== null) { |
| startFrameIdx = data.start_frame_idx; |
| console.log(`Sync: start_frame_idx=${startFrameIdx}`); |
| } |
| log(`Primeiro frame (server): ${data.latency_ms}ms`, "status"); |
| break; |
| |
| case "done": |
| |
| if (data.start_frame_idx !== undefined && data.start_frame_idx !== null) { |
| startFrameIdx = data.start_frame_idx; |
| } |
| if (data.end_frame_idx !== undefined && data.end_frame_idx !== null) { |
| endFrameIdx = data.end_frame_idx; |
| } |
| if (data.end_video_time_ms !== undefined) { |
| endVideoTimeMs = data.end_video_time_ms; |
| } |
| console.log(`Sync: start_frame=${startFrameIdx}, end_frame=${endFrameIdx}, end_time_ms=${endVideoTimeMs}`); |
| |
| const framesCount = data.total_frames || data.frames || allFrames.length; |
| const bytesInfo = data.bytes_sent ? `${(data.bytes_sent/1024).toFixed(1)}KB` : ''; |
| log(`Recebido: ${framesCount} frames ${bytesInfo}`, "msg"); |
| streamDone = true; |
| console.log(`Stream done: ${allFrames.length} frames, audioChunks=${audioChunks.length}`); |
| |
| tryStartStreamingPlayback(); |
| break; |
| |
| case "status": |
| log(data.message, "status"); |
| break; |
| |
| case "error": |
| log("Erro: " + data.message, "error"); |
| stopStream(); |
| break; |
| } |
| } |
| |
| function handleFrame(frameData, frameIndex) { |
| |
| const blob = new Blob([frameData], { type: "image/jpeg" }); |
| const url = URL.createObjectURL(blob); |
| |
| |
| allFrames.push({ url, index: frameIndex }); |
| |
| |
| if (progressivePlaybackStarted || playbackStarted) { |
| frameQueue.push({ url, index: frameIndex }); |
| document.getElementById("buffer").textContent = frameQueue.length; |
| } |
| |
| const received = allFrames.length; |
| document.getElementById("frames").textContent = received; |
| |
| |
| const videoDuration = received / TARGET_FPS; |
| document.getElementById("video-duration").textContent = videoDuration.toFixed(2) + "s"; |
| updateSyncDiff(); |
| |
| if (!progressivePlaybackStarted && !playbackStarted) { |
| document.getElementById("buffer").textContent = received + " (buffering)"; |
| } |
| |
| if (received === 1) { |
| console.log("Primeiro frame recebido - buffering..."); |
| updateStatus("stream-status", "streaming", "Stream: Buffering..."); |
| } |
| |
| |
| tryStartStreamingPlayback(); |
| } |
| |
| function startAudioPlayback() { |
| if (!audioBuffer || !audioContext) return; |
| |
| if (audioSource) { |
| try { audioSource.stop(); } catch (e) {} |
| } |
| |
| audioSource = audioContext.createBufferSource(); |
| audioSource.buffer = audioBuffer; |
| audioSource.connect(audioContext.destination); |
| audioSource.start(); |
| |
| |
| audioSource.onended = () => { |
| console.log("Audio terminou"); |
| }; |
| } |
| |
| |
| |
| |
| |
| function handleAudioChunk(chunkData, chunkIndex, isLast) { |
| |
| if (audioChunks.length === 0 && chunkData.length > 0) { |
| firstChunkTime = Date.now(); |
| initAudioContext(); |
| } |
| |
| if (chunkData.length > 0) { |
| |
| audioChunks.push(chunkData); |
| |
| |
| const alignedBuffer = new ArrayBuffer(chunkData.length); |
| new Uint8Array(alignedBuffer).set(chunkData); |
| const samples = new Int16Array(alignedBuffer); |
| totalAudioSamples += samples.length; |
| |
| |
| const totalAudioDuration = totalAudioSamples / 24000; |
| document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s"; |
| updateSyncDiff(); |
| |
| |
| if (PROGRESSIVE_MODE && progressivePlaybackStarted) { |
| scheduleAudioChunkProgressive(chunkData); |
| } |
| |
| console.log(`Audio chunk ${chunkIndex}: ${chunkData.length} bytes, scheduled=${audioScheduledChunks}`); |
| } |
| |
| if (isLast) { |
| audioChunksComplete = true; |
| log(`Audio completo: ${audioChunks.length} chunks`, "status"); |
| console.log(`Todos chunks recebidos: ${audioChunks.length}`); |
| } |
| |
| |
| tryStartStreamingPlayback(); |
| } |
| |
| function scheduleAudioChunkProgressive(chunkData) { |
| |
| if (!audioContext) return; |
| |
| try { |
| |
| const alignedBuffer = new ArrayBuffer(chunkData.length); |
| new Uint8Array(alignedBuffer).set(chunkData); |
| const samples = new Int16Array(alignedBuffer); |
| const floatSamples = new Float32Array(samples.length); |
| for (let i = 0; i < samples.length; i++) { |
| floatSamples[i] = samples[i] / 32768; |
| } |
| |
| |
| const buffer = audioContext.createBuffer(1, floatSamples.length, 24000); |
| buffer.getChannelData(0).set(floatSamples); |
| |
| |
| const source = audioContext.createBufferSource(); |
| source.buffer = buffer; |
| source.connect(audioContext.destination); |
| |
| |
| const now = audioContext.currentTime; |
| if (nextAudioTime < now) { |
| nextAudioTime = now + 0.01; |
| } |
| |
| |
| source.start(nextAudioTime); |
| |
| |
| const chunkDuration = floatSamples.length / 24000; |
| nextAudioTime += chunkDuration; |
| audioScheduledChunks++; |
| |
| |
| const audioEndContextTime = nextAudioTime; |
| const elapsedSinceStart = audioEndContextTime - audioContextStartTime; |
| audioExpectedEndTime = audioPlaybackStartTime + (elapsedSinceStart * 1000); |
| |
| } catch (e) { |
| console.error("Erro agendando audio chunk:", e); |
| } |
| } |
| |
| function initAudioContext() { |
| if (!audioContext) { |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ |
| sampleRate: 24000 |
| }); |
| } |
| |
| nextAudioTime = audioContext.currentTime + 0.05; |
| audioScheduledChunks = 0; |
| } |
| |
| function updateSyncDiff() { |
| |
| const videoDuration = allFrames.length / TARGET_FPS; |
| const audioDuration = totalAudioSamples / 24000; |
| |
| if (videoDuration > 0 && audioDuration > 0) { |
| const diff = audioDuration - videoDuration; |
| const diffEl = document.getElementById("sync-diff"); |
| diffEl.textContent = (diff >= 0 ? "+" : "") + diff.toFixed(2) + "s"; |
| |
| |
| if (Math.abs(diff) < 0.1) { |
| diffEl.style.color = "#0f0"; |
| } else if (Math.abs(diff) < 0.5) { |
| diffEl.style.color = "#fc0"; |
| } else { |
| diffEl.style.color = "#f00"; |
| } |
| } |
| } |
| |
| function scheduleAudioChunk(chunkData) { |
| if (!audioContext) return; |
| |
| try { |
| |
| const alignedBuffer = new ArrayBuffer(chunkData.length); |
| new Uint8Array(alignedBuffer).set(chunkData); |
| const samples = new Int16Array(alignedBuffer); |
| const floatSamples = new Float32Array(samples.length); |
| for (let i = 0; i < samples.length; i++) { |
| floatSamples[i] = samples[i] / 32768; |
| } |
| |
| |
| totalAudioSamples += samples.length; |
| |
| |
| const chunkBuffer = audioContext.createBuffer(1, floatSamples.length, 24000); |
| chunkBuffer.getChannelData(0).set(floatSamples); |
| |
| |
| const source = audioContext.createBufferSource(); |
| source.buffer = chunkBuffer; |
| source.connect(audioContext.destination); |
| |
| |
| if (nextAudioTime < audioContext.currentTime) { |
| nextAudioTime = audioContext.currentTime + 0.01; |
| } |
| |
| source.start(nextAudioTime); |
| |
| |
| const chunkDuration = floatSamples.length / 24000; |
| nextAudioTime += chunkDuration; |
| audioScheduledChunks++; |
| |
| |
| const totalAudioDuration = totalAudioSamples / 24000; |
| document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s"; |
| updateSyncDiff(); |
| |
| |
| if (audioScheduledChunks === 1) { |
| const latency = Date.now() - startTime; |
| console.log(`Primeiro audio chunk agendado: latencia=${latency}ms`); |
| } |
| |
| } catch (e) { |
| console.error("Erro agendando audio chunk:", e); |
| } |
| } |
| |
| async function handleAudioComplete(audioData, sampleRate) { |
| |
| try { |
| if (!audioContext) { |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ |
| sampleRate: sampleRate |
| }); |
| } |
| |
| const alignedBuffer = new ArrayBuffer(audioData.length); |
| new Uint8Array(alignedBuffer).set(audioData); |
| |
| const samples = new Int16Array(alignedBuffer); |
| const floatSamples = new Float32Array(samples.length); |
| for (let i = 0; i < samples.length; i++) { |
| floatSamples[i] = samples[i] / 32768; |
| } |
| |
| audioBuffer = audioContext.createBuffer(1, floatSamples.length, sampleRate); |
| audioBuffer.getChannelData(0).set(floatSamples); |
| audioDuration = audioBuffer.duration; |
| audioChunksComplete = true; |
| |
| log(`Audio completo: ${(audioData.length/1024).toFixed(1)}KB, ${audioDuration.toFixed(2)}s`, "status"); |
| |
| tryStartStreamingPlayback(); |
| |
| } catch (e) { |
| log("Erro audio: " + e, "error"); |
| console.error("Erro processando audio:", e); |
| } |
| } |
| |
| function tryStartStreamingPlayback() { |
| |
| if (playbackStarted) return; |
| |
| const framesCount = allFrames.length; |
| const audioChunksCount = audioChunks.length; |
| |
| |
| if (PROGRESSIVE_MODE && !progressivePlaybackStarted) { |
| |
| const hasMinFrames = framesCount >= MIN_FRAMES_TO_START; |
| const hasMinAudio = audioChunksCount >= MIN_AUDIO_CHUNKS_TO_START; |
| |
| if (hasMinFrames && hasMinAudio) { |
| console.log(`=== INICIANDO REPRODUÇÃO PROGRESSIVA ===`); |
| console.log(`Buffer: ${framesCount} frames, ${audioChunksCount} chunks de áudio`); |
| |
| progressivePlaybackStarted = true; |
| startProgressivePlayback(); |
| return; |
| } else { |
| |
| const status = `Buffering: ${framesCount}/${MIN_FRAMES_TO_START} frames, ${audioChunksCount}/${MIN_AUDIO_CHUNKS_TO_START} chunks`; |
| document.getElementById("buffer").textContent = status; |
| return; |
| } |
| } |
| |
| |
| if (streamDone && progressivePlaybackStarted && !playbackStarted) { |
| playbackStarted = true; |
| |
| |
| const audioDurationSec = totalAudioSamples / 24000; |
| const calculatedFps = framesCount / audioDurationSec; |
| syncedFrameInterval = 1000 / calculatedFps; |
| |
| console.log(`=== AJUSTE FINAL DE SYNC ===`); |
| console.log(`Frames: ${framesCount}, Audio: ${audioDurationSec.toFixed(2)}s`); |
| console.log(`FPS ajustado: ${calculatedFps.toFixed(2)} (interval: ${syncedFrameInterval.toFixed(1)}ms)`); |
| return; |
| } |
| |
| |
| if (!PROGRESSIVE_MODE && streamDone) { |
| if (framesCount === 0 || totalAudioSamples === 0) return; |
| |
| playbackStarted = true; |
| |
| const playbackLatency = Date.now() - startTime; |
| document.getElementById("latency").textContent = playbackLatency + "ms"; |
| log(`Latencia: ${playbackLatency}ms`, "status"); |
| |
| const audioDurationSec = totalAudioSamples / 24000; |
| const calculatedFps = framesCount / audioDurationSec; |
| syncedFrameInterval = 1000 / calculatedFps; |
| |
| frameQueue = [...allFrames]; |
| renderSource = 'speaking'; |
| lastSpeakingRenderTime = performance.now(); |
| restartAudioPlayback(); |
| |
| updateStatus("stream-status", "streaming", "Stream: Reproduzindo"); |
| } |
| } |
| |
| function startProgressivePlayback() { |
| |
| const playbackLatency = Date.now() - startTime; |
| document.getElementById("latency").textContent = playbackLatency + "ms"; |
| log(`Latencia: ${playbackLatency}ms`, "status"); |
| |
| |
| syncedFrameInterval = FRAME_INTERVAL; |
| |
| console.log(`Iniciando progressivo: ${allFrames.length} frames, ${audioChunks.length} chunks`); |
| |
| |
| frameQueue = [...allFrames]; |
| document.getElementById("buffer").textContent = frameQueue.length; |
| |
| |
| |
| if (startFrameIdx !== null && idleVideoTotalFrames > 0) { |
| waitingForFrameSync = true; |
| frameSyncStartTime = performance.now(); |
| console.log(`Frame sync: aguardando frame ${startFrameIdx} de ${idleVideoTotalFrames}`); |
| updateStatus("stream-status", "streaming", `Sync: aguardando frame ${startFrameIdx}...`); |
| |
| return; |
| } |
| |
| |
| doStartSpeaking(); |
| } |
| |
| function doStartSpeaking() { |
| |
| renderSource = 'speaking'; |
| lastSpeakingRenderTime = performance.now(); |
| |
| |
| startProgressiveAudio(); |
| |
| updateStatus("stream-status", "streaming", "Stream: Reproduzindo..."); |
| } |
| |
| function startProgressiveAudio() { |
| if (!audioContext) { |
| initAudioContext(); |
| } |
| |
| |
| audioPlaybackStartTime = performance.now(); |
| audioContextStartTime = audioContext.currentTime; |
| nextAudioTime = audioContext.currentTime + 0.02; |
| |
| |
| for (const chunk of audioChunks) { |
| scheduleAudioChunkProgressive(chunk); |
| } |
| |
| console.log(`Audio progressivo: ${audioScheduledChunks} chunks agendados`); |
| } |
| |
| function restartAudioPlayback() { |
| |
| if (!audioContext) return; |
| |
| |
| if (audioChunks.length === 0) return; |
| |
| try { |
| |
| if (currentAudioSource) { |
| try { currentAudioSource.stop(); } catch (e) {} |
| currentAudioSource = null; |
| } |
| |
| const totalLength = audioChunks.reduce((sum, c) => sum + c.length, 0); |
| const combined = new Uint8Array(totalLength); |
| let offset = 0; |
| for (const chunk of audioChunks) { |
| combined.set(chunk, offset); |
| offset += chunk.length; |
| } |
| |
| |
| const alignedBuffer = new ArrayBuffer(combined.length); |
| new Uint8Array(alignedBuffer).set(combined); |
| const samples = new Int16Array(alignedBuffer); |
| const floatSamples = new Float32Array(samples.length); |
| for (let i = 0; i < samples.length; i++) { |
| floatSamples[i] = samples[i] / 32768; |
| } |
| |
| |
| const buffer = audioContext.createBuffer(1, floatSamples.length, 24000); |
| buffer.getChannelData(0).set(floatSamples); |
| |
| const source = audioContext.createBufferSource(); |
| source.buffer = buffer; |
| source.connect(audioContext.destination); |
| |
| |
| audioPlaybackStartTime = performance.now(); |
| audioExpectedEndTime = audioPlaybackStartTime + (buffer.duration * 1000); |
| |
| |
| source.onended = () => { |
| console.log("Audio playback ended (onended callback)"); |
| |
| if (renderSource === 'speaking') { |
| finishPlayback(); |
| } |
| }; |
| |
| source.start(); |
| currentAudioSource = source; |
| |
| console.log(`Audio reiniciado: ${buffer.duration.toFixed(2)}s, end expected at ${audioExpectedEndTime.toFixed(0)}ms`); |
| |
| } catch (e) { |
| console.error("Erro reiniciando audio:", e); |
| } |
| } |
| |
| async function prepareAndPlayAudio() { |
| try { |
| if (!audioContext) { |
| audioContext = new (window.AudioContext || window.webkitAudioContext)({ |
| sampleRate: 24000 |
| }); |
| } |
| |
| |
| if (audioBuffer) { |
| startAudioPlayback(); |
| return; |
| } |
| |
| |
| if (audioChunks.length === 0) return; |
| |
| const totalLength = audioChunks.reduce((sum, c) => sum + c.length, 0); |
| const combined = new Uint8Array(totalLength); |
| let offset = 0; |
| for (const chunk of audioChunks) { |
| combined.set(chunk, offset); |
| offset += chunk.length; |
| } |
| |
| |
| const alignedBuffer = new ArrayBuffer(combined.length); |
| new Uint8Array(alignedBuffer).set(combined); |
| const samples = new Int16Array(alignedBuffer); |
| const floatSamples = new Float32Array(samples.length); |
| for (let i = 0; i < samples.length; i++) { |
| floatSamples[i] = samples[i] / 32768; |
| } |
| |
| audioBuffer = audioContext.createBuffer(1, floatSamples.length, 24000); |
| audioBuffer.getChannelData(0).set(floatSamples); |
| audioDuration = audioBuffer.duration; |
| |
| console.log(`Audio preparado: ${audioDuration.toFixed(2)}s de ${audioChunks.length} chunks`); |
| |
| startAudioPlayback(); |
| |
| } catch (e) { |
| log("Erro preparando audio: " + e, "error"); |
| console.error(e); |
| } |
| } |
| |
| function generate() { |
| const text = document.getElementById("text-input").value.trim(); |
| const voice = document.getElementById("voice-select").value; |
| |
| if (!text) { log("Digite texto", "error"); return; } |
| if (!ws || ws.readyState !== WebSocket.OPEN) { log("WS desconectado", "error"); return; } |
| |
| |
| |
| |
| const idleVideoTimeMs = getCompensatedIdleVideoTime(); |
| console.log(`Idle video compensated: ${idleVideoTimeMs}ms (latency: ${networkLatencyMs.toFixed(0)}ms)`); |
| |
| stopStream(); |
| |
| |
| frameQueue = []; |
| allFrames = []; |
| renderedFrames = 0; |
| totalBytes = 0; |
| startTime = Date.now(); |
| |
| |
| audioChunks = []; |
| audioChunksComplete = false; |
| streamDone = false; |
| audioDuration = 0; |
| audioBuffer = null; |
| syncedFrameInterval = FRAME_INTERVAL; |
| playbackStarted = false; |
| nextAudioTime = 0; |
| audioScheduledChunks = 0; |
| firstChunkTime = null; |
| totalAudioSamples = 0; |
| currentAudioSource = null; |
| audioPlaybackStartTime = 0; |
| audioExpectedEndTime = 0; |
| audioContextStartTime = 0; |
| progressivePlaybackStarted = false; |
| |
| |
| startFrameIdx = null; |
| endFrameIdx = null; |
| endVideoTimeMs = null; |
| waitingForFrameSync = false; |
| |
| document.getElementById("frames").textContent = "0"; |
| document.getElementById("rendered").textContent = "0"; |
| document.getElementById("buffer").textContent = "0"; |
| document.getElementById("bytes").textContent = "0 KB"; |
| document.getElementById("latency").textContent = "--"; |
| document.getElementById("video-duration").textContent = "--"; |
| document.getElementById("audio-duration").textContent = "--"; |
| document.getElementById("sync-diff").textContent = "--"; |
| document.getElementById("sync-diff").style.color = ""; |
| |
| isStreaming = true; |
| document.getElementById("generate-btn").disabled = true; |
| document.getElementById("stop-btn").disabled = false; |
| updateStatus("stream-status", "", "Stream: Iniciando..."); |
| |
| |
| const quality = parseInt(document.getElementById("quality-slider").value); |
| |
| |
| |
| const localStartFrameIdx = Math.floor((idleVideoTimeMs / 1000) * IDLE_VIDEO_FPS) % idleVideoTotalFrames; |
| startFrameIdx = localStartFrameIdx; |
| console.log(`Frame sync local: start_frame_idx=${startFrameIdx} (de ${idleVideoTotalFrames} frames)`); |
| |
| log("Enviando: " + text.substring(0, 30) + "...", "status"); |
| |
| |
| ws.send(JSON.stringify({ |
| action: "generate", |
| text: text, |
| voice: voice, |
| idle_video_time_ms: idleVideoTimeMs, |
| jpeg_quality: quality |
| })); |
| } |
| |
| function stop() { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ action: "stop" })); |
| } |
| stopStream(); |
| } |
| |
| function finishPlayback() { |
| |
| if (renderSource === 'idle') { |
| console.log("finishPlayback: já está em idle, ignorando"); |
| return; |
| } |
| |
| |
| if (renderInterval) { |
| clearInterval(renderInterval); |
| renderInterval = null; |
| } |
| |
| |
| const elapsed = (Date.now() - startTime) / 1000; |
| log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg"); |
| console.log(`Playback finalizado: ${renderedFrames} frames renderizados`); |
| |
| |
| |
| |
| if (endVideoTimeMs !== null && endVideoTimeMs > 0) { |
| const videoDuration = idleVideo.duration || 60; |
| const videoDurationMs = videoDuration * 1000; |
| |
| |
| const totalElapsedMs = Date.now() - startTime; |
| |
| |
| const audioDurationMs = totalAudioSamples / 24000 * 1000; |
| |
| |
| const networkOverheadMs = Math.max(0, totalElapsedMs - audioDurationMs); |
| |
| |
| const compensatedTimeMs = endVideoTimeMs + networkOverheadMs; |
| const seekTime = (compensatedTimeMs % videoDurationMs) / 1000; |
| |
| console.log(`Transição: end=${endVideoTimeMs}ms, elapsed=${totalElapsedMs}ms, audio=${audioDurationMs.toFixed(0)}ms, overhead=${networkOverheadMs.toFixed(0)}ms -> seek=${seekTime.toFixed(2)}s`); |
| idleVideo.currentTime = seekTime; |
| } |
| |
| |
| idleVideo.loop = true; |
| idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e)); |
| |
| |
| renderSource = 'idle'; |
| console.log("Transição direta para idle"); |
| |
| |
| audioExpectedEndTime = 0; |
| audioPlaybackStartTime = 0; |
| |
| |
| isStreaming = false; |
| playbackStarted = false; |
| progressivePlaybackStarted = false; |
| |
| |
| startFrameIdx = null; |
| endFrameIdx = null; |
| endVideoTimeMs = null; |
| waitingForFrameSync = false; |
| document.getElementById("generate-btn").disabled = false; |
| document.getElementById("stop-btn").disabled = true; |
| updateStatus("stream-status", "online", "Stream: Concluido"); |
| } |
| |
| function stopStream() { |
| isStreaming = false; |
| |
| if (renderInterval) { |
| clearInterval(renderInterval); |
| renderInterval = null; |
| } |
| |
| |
| frameQueue.forEach(f => URL.revokeObjectURL(f.url)); |
| frameQueue = []; |
| |
| |
| allFrames.forEach(f => URL.revokeObjectURL(f.url)); |
| allFrames = []; |
| |
| |
| audioChunks = []; |
| audioChunksComplete = false; |
| streamDone = false; |
| audioDuration = 0; |
| audioBuffer = null; |
| playbackStarted = false; |
| progressivePlaybackStarted = false; |
| nextAudioTime = 0; |
| audioScheduledChunks = 0; |
| firstChunkTime = null; |
| totalAudioSamples = 0; |
| audioPlaybackStartTime = 0; |
| audioExpectedEndTime = 0; |
| audioContextStartTime = 0; |
| |
| if (currentAudioSource) { |
| try { currentAudioSource.stop(); } catch (e) {} |
| currentAudioSource = null; |
| } |
| |
| if (audioSource) { |
| try { audioSource.stop(); } catch (e) {} |
| audioSource = null; |
| } |
| |
| |
| startFrameIdx = null; |
| endFrameIdx = null; |
| endVideoTimeMs = null; |
| waitingForFrameSync = false; |
| |
| |
| renderSource = 'idle'; |
| idleVideo.play().catch(e => {}); |
| console.log("stopStream: Transição para idle"); |
| |
| document.getElementById("generate-btn").disabled = false; |
| document.getElementById("stop-btn").disabled = true; |
| updateStatus("stream-status", "", "Stream: Idle"); |
| } |
| |
| |
| setInterval(() => { |
| if (ws && ws.readyState === WebSocket.OPEN) { |
| ws.send(JSON.stringify({ action: "ping" })); |
| } |
| }, 30000); |
| |
| |
| let demoMode = false; |
| let demoTimeout = null; |
| let lastDemoText = ""; |
| |
| function toggleDemo() { |
| if (demoMode) { |
| stopDemo(); |
| } else { |
| startDemo(); |
| } |
| } |
| |
| function startDemo() { |
| demoMode = true; |
| lastDemoText = document.getElementById("text-input").value.trim(); |
| |
| if (!lastDemoText) { |
| lastDemoText = "Hello! I am a real-time streaming avatar optimized for low latency."; |
| document.getElementById("text-input").value = lastDemoText; |
| } |
| |
| document.getElementById("demo-btn").textContent = "Parar Demo"; |
| document.getElementById("demo-btn").style.background = "linear-gradient(135deg, #f44, #c00)"; |
| document.getElementById("generate-btn").disabled = true; |
| document.getElementById("text-input").disabled = true; |
| |
| log("Modo demo iniciado", "status"); |
| console.log("Demo: iniciando ciclo"); |
| |
| |
| demoSpeak(); |
| } |
| |
| function stopDemo() { |
| demoMode = false; |
| if (demoTimeout) { |
| clearTimeout(demoTimeout); |
| demoTimeout = null; |
| } |
| |
| document.getElementById("demo-btn").textContent = "Iniciar Demo"; |
| document.getElementById("demo-btn").style.background = "linear-gradient(135deg, #fc0, #f90)"; |
| document.getElementById("generate-btn").disabled = false; |
| document.getElementById("text-input").disabled = false; |
| |
| stop(); |
| log("Modo demo parado", "status"); |
| console.log("Demo: parado"); |
| } |
| |
| function demoSpeak() { |
| if (!demoMode) return; |
| |
| console.log("Demo: falando..."); |
| |
| |
| const text = document.getElementById("text-input").value.trim() || lastDemoText; |
| const voice = document.getElementById("voice-select").value; |
| const quality = parseInt(document.getElementById("quality-slider").value); |
| const idleVideoTimeMs = getCompensatedIdleVideoTime(); |
| |
| |
| stopStream(); |
| frameQueue = []; |
| allFrames = []; |
| renderedFrames = 0; |
| totalBytes = 0; |
| startTime = Date.now(); |
| audioChunks = []; |
| audioChunksComplete = false; |
| streamDone = false; |
| audioDuration = 0; |
| audioBuffer = null; |
| syncedFrameInterval = FRAME_INTERVAL; |
| playbackStarted = false; |
| nextAudioTime = 0; |
| audioScheduledChunks = 0; |
| firstChunkTime = null; |
| totalAudioSamples = 0; |
| currentAudioSource = null; |
| audioPlaybackStartTime = 0; |
| audioExpectedEndTime = 0; |
| audioContextStartTime = 0; |
| progressivePlaybackStarted = false; |
| |
| |
| endFrameIdx = null; |
| endVideoTimeMs = null; |
| waitingForFrameSync = false; |
| |
| |
| const localStartFrameIdx = Math.floor((idleVideoTimeMs / 1000) * IDLE_VIDEO_FPS) % idleVideoTotalFrames; |
| startFrameIdx = localStartFrameIdx; |
| console.log(`Demo frame sync: start_frame_idx=${startFrameIdx}`); |
| |
| isStreaming = true; |
| updateStatus("stream-status", "streaming", "Demo: Falando..."); |
| |
| ws.send(JSON.stringify({ |
| action: "generate", |
| text: text, |
| voice: voice, |
| idle_video_time_ms: idleVideoTimeMs, |
| jpeg_quality: quality |
| })); |
| } |
| |
| function demoScheduleNextCycle() { |
| if (!demoMode) return; |
| |
| const idleTime = parseInt(document.getElementById("demo-idle-slider").value) * 1000; |
| console.log(`Demo: aguardando ${idleTime}ms em idle...`); |
| updateStatus("stream-status", "online", `Demo: Idle (${idleTime/1000}s)`); |
| |
| demoTimeout = setTimeout(() => { |
| if (demoMode) { |
| demoSpeak(); |
| } |
| }, idleTime); |
| } |
| |
| |
| const originalFinishPlayback = finishPlayback; |
| finishPlayback = function() { |
| originalFinishPlayback(); |
| |
| |
| if (demoMode) { |
| demoScheduleNextCycle(); |
| } |
| }; |
| |
| connectWebSocket(); |
| </script> |
| </body> |
| </html> |
|
|