speech2speech-interface / interface /index_optimized.html
marcosremar2's picture
Add WebRTC streaming interface with vast.ai deployment
e62aafd
Raw
History Blame Contribute Delete
67.5 kB
<!DOCTYPE html>
<!--
Avatar - Optimized Streaming
VERSAO OTIMIZADA - 2024-12-27
Otimizações:
1. Recebe frames como WebSocket binário (sem base64)
2. Decodificação mais eficiente
3. -33% de bandwidth
Protocolo binário:
- Header: [tipo:1][param1:4][param2:4][dados...]
- Tipo 0x01: Frame JPEG
- Tipo 0x02: Audio PCM completo
- Tipo 0x03: Audio chunk (futuro)
-->
<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;
}
/* Video idle fica oculto - só é usado como fonte para o canvas */
#idle-video {
position: absolute; top: 0; left: 0;
width: 1px; height: 1px;
opacity: 0;
pointer-events: none;
}
/* Canvas sempre visível - renderiza tanto idle quanto speaking */
#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;
// Tipos de mensagem binária (devem corresponder ao server)
const MSG_FRAME = 0x01;
const MSG_AUDIO = 0x02;
const MSG_AUDIO_CHUNK = 0x03;
let ws = null;
let isStreaming = false;
let startTime = null;
// Frame buffer - agora acumula até áudio chegar
let frameQueue = [];
let allFrames = []; // Todos os frames recebidos
let renderedFrames = 0;
let renderInterval = null;
let lastRenderTime = 0;
let totalBytes = 0;
// Audio - streaming real com scheduling
let audioContext = null;
let audioSource = null;
let audioBuffer = null;
let audioDuration = 0;
let audioChunks = []; // Chunks recebidos
let audioChunksComplete = false; // Todos chunks recebidos?
let streamDone = false;
let syncedFrameInterval = FRAME_INTERVAL;
let playbackStarted = false;
// === REPRODUÇÃO PROGRESSIVA ===
// Configurações de buffer mínimo para começar a reproduzir
const MIN_FRAMES_TO_START = 5; // Mínimo de frames antes de iniciar (200ms de vídeo)
const MIN_AUDIO_CHUNKS_TO_START = 2; // Mínimo de chunks de áudio antes de iniciar
const PROGRESSIVE_MODE = true; // Ativar reprodução progressiva
// Streaming de audio real - agendamento sequencial
let nextAudioTime = 0; // Próximo tempo para agendar audio
let audioScheduledChunks = 0; // Quantos chunks já agendamos
let firstChunkTime = null; // Tempo do primeiro chunk (para latência)
let totalAudioSamples = 0; // Total de samples de audio recebidos
let currentAudioSource = null; // Referência ao audio source atual
let audioPlaybackStartTime = 0; // Tempo em que o audio começou a tocar (performance.now)
let audioExpectedEndTime = 0; // Tempo esperado para o audio terminar (performance.now)
let progressivePlaybackStarted = false; // Flag para reprodução progressiva
let audioContextStartTime = 0; // audioContext.currentTime quando começou
// Sincronização de transição no nível de frame
let endVideoTimeMs = null; // Tempo para continuar o vídeo idle após fala
let startFrameIdx = null; // Frame inicial que o Wav2Lip usou
let endFrameIdx = null; // Frame final que o Wav2Lip usou
let waitingForFrameSync = false; // Esperando o frame certo para começar
let frameSyncStartTime = 0; // Quando começou a esperar pelo frame
const FRAME_SYNC_TIMEOUT = 500; // Timeout máximo em ms para sincronização
let idleVideoDurationMs = 0; // Duração total do vídeo idle em ms
let idleVideoTotalFrames = 0; // Total de frames do vídeo idle
// Renderização unificada no canvas
let renderSource = 'idle'; // 'idle' = video, 'speaking' = frames do servidor
let unifiedRenderLoop = null; // ID do requestAnimationFrame
// Medição de latência de rede
let networkLatencyMs = 100; // Latência estimada (RTT/2), começa com valor default
let lastPingSentAt = null; // Timestamp do último ping
let latencyHistory = []; // Histórico de latências para média móvel
const LATENCY_SAMPLES = 5; // Quantidade de amostras para média
const IDLE_VIDEO_FPS = 25; // FPS do vídeo idle
// Canvas e Video Idle
const canvas = document.getElementById("avatar-canvas");
const ctx = canvas.getContext("2d");
const idleVideo = document.getElementById("idle-video");
// Iniciar video idle (escondido - só o canvas aparece)
idleVideo.play().catch(e => console.log("Autoplay blocked:", e));
// Render loop unificado - sempre desenha no canvas
// Quando idle: desenha frame do vídeo
// Quando speaking: desenha frames recebidos do servidor
let speakingFrameIndex = 0;
let lastSpeakingRenderTime = 0;
// Transição direta (sem crossfade para parecer mais natural)
function startUnifiedRenderLoop() {
if (unifiedRenderLoop) return; // Já está rodando
function renderFrame() {
// === ESTADO: AGUARDANDO SINCRONIZAÇÃO DE FRAME ===
if (waitingForFrameSync && startFrameIdx !== null) {
// Continuar mostrando idle video enquanto aguarda
if (idleVideo.readyState >= 2) {
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
}
// Verificar timeout - se demorou muito, iniciar mesmo assim
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;
}
// Calcular frame atual do vídeo idle
const currentIdleFrame = Math.floor(idleVideo.currentTime * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
// Verificar se chegou no frame alvo (com tolerância de ±2 frames)
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') {
// Transição direta - sem fade, apenas troca para o vídeo idle
// O vídeo idle já está sincronizado no ponto correto (endVideoTimeMs)
if (idleVideo.readyState >= 2) {
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
} else {
// Video não está pronto - tentar recarregar
console.log(`[IDLE] Video não pronto: readyState=${idleVideo.readyState}, tentando play...`);
idleVideo.play().catch(e => {});
}
} else if (renderSource === 'speaking') {
// Desenha frames do servidor com timing controlado
const now = performance.now();
const elapsed = now - lastSpeakingRenderTime;
// === VERIFICAR SE DEVE FINALIZAR ===
// Debug: mostrar estado a cada segundo
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)}`);
}
// 1. Áudio terminou (tempo esperado passou + offset do usuário)
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();
// NÃO fazer return aqui - precisa continuar o render loop para o idle
}
// 2. Stream completo e fila de frames vazia
else if (streamDone && frameQueue.length === 0) {
console.log(`[FIM] Stream done + fila vazia: rendered=${renderedFrames}, total=${allFrames.length}`);
finishPlayback();
// NÃO fazer return aqui - precisa continuar o render loop para o idle
}
// === RENDERIZAR PRÓXIMO FRAME ===
if (elapsed >= syncedFrameInterval && frameQueue.length > 0) {
const frameData = frameQueue.shift();
// Carregar imagem a partir da URL
const img = new Image();
img.onload = () => {
// Ajustar canvas se necessário
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); // Liberar memória
};
img.src = frameData.url;
renderedFrames++;
document.getElementById("rendered").textContent = renderedFrames;
document.getElementById("buffer").textContent = frameQueue.length;
lastSpeakingRenderTime = now;
// Atualizar FPS
const fps = 1000 / elapsed;
updateStatus("fps-status", "", `FPS: ${fps.toFixed(0)}`);
// Bandwidth
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");
}
}
// Iniciar o render loop quando a página carrega
idleVideo.addEventListener('loadeddata', () => {
// Ajustar tamanho do canvas para match do vídeo
canvas.width = idleVideo.videoWidth || 512;
canvas.height = idleVideo.videoHeight || 512;
// Capturar duração e calcular total de frames
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`);
// Garantir que o vídeo idle está tocando em loop
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();
});
});
// Fallback: se o vídeo não carregar em 3 segundos, iniciar mesmo assim
setTimeout(() => {
if (!unifiedRenderLoop) {
console.log("Fallback: iniciando render loop após timeout");
canvas.width = 512;
canvas.height = 512;
startUnifiedRenderLoop();
}
}, 3000);
// Tratamento de erro do vídeo idle
idleVideo.addEventListener('error', (e) => {
console.error("Erro carregando vídeo idle:", e);
log("Erro carregando vídeo idle", "error");
// Tentar carregar novamente após 2 segundos
setTimeout(() => {
console.log("Tentando recarregar vídeo idle...");
idleVideo.load();
}, 2000);
});
// Quando o vídeo idle termina (não deveria acontecer com loop=true, mas por segurança)
idleVideo.addEventListener('ended', () => {
console.log("Video idle ended - reiniciando");
idleVideo.currentTime = 0;
idleVideo.play().catch(e => console.log("Erro reiniciando idle:", e));
});
// Quando o vídeo idle para por algum motivo
idleVideo.addEventListener('pause', () => {
if (renderSource === 'idle') {
console.log("Video idle pausou inesperadamente - retomando");
idleVideo.play().catch(e => console.log("Erro retomando idle:", e));
}
});
// Forçar carregamento do vídeo idle com cache-busting
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"; // Importante para receber binário
ws.onopen = () => {
updateStatus("ws-status", "online", "WS: Conectado");
log("Conectado (binary mode)", "msg");
// Iniciar medição de latência
measureLatency();
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Mensagem binária
handleBinaryMessage(event.data);
} else {
// Mensagem JSON
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");
}
// Medir latência de rede (RTT)
function measureLatency() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
lastPingSentAt = performance.now();
ws.send(JSON.stringify({ action: "ping", timestamp: lastPingSentAt }));
}
// Processar resposta de latência
function handlePong(serverTimestamp) {
if (!lastPingSentAt) return;
const rtt = performance.now() - lastPingSentAt;
const oneWayLatency = rtt / 2;
// Adicionar ao histórico
latencyHistory.push(oneWayLatency);
if (latencyHistory.length > LATENCY_SAMPLES) {
latencyHistory.shift();
}
// Calcular média móvel
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`);
// Agendar próxima medição (a cada 5 segundos)
setTimeout(measureLatency, 5000);
}
// Calcular timestamp compensado para gerar vídeo no frame certo
function getCompensatedIdleVideoTime() {
const currentTimeMs = idleVideo.currentTime * 1000;
const videoDurationMs = (idleVideo.duration || 60) * 1000;
// Compensar: tempo atual + latência de ida + tempo estimado de processamento
// O servidor vai receber o request depois de networkLatencyMs
// E vai começar a gerar o vídeo, que precisa casar com o frame nesse momento
const processingEstimateMs = 50; // Estimativa de overhead de processamento
const compensatedTime = currentTimeMs + networkLatencyMs + processingEstimateMs;
// Garantir que está dentro do range do vídeo (loop)
return Math.round(compensatedTime % videoDurationMs);
}
function handleBinaryMessage(buffer) {
const view = new DataView(buffer);
const msgType = view.getUint8(0);
if (msgType === MSG_FRAME) {
// Frame: [tipo:1][index:4][tamanho:4][dados]
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) {
// Audio completo: [tipo:1][sample_rate:4][tamanho:4][dados]
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) {
// Audio chunk: [tipo:1][chunk_index:4][is_last:1][tamanho:4][dados]
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":
// Resposta do ping - calcular latência
handlePong(data.timestamp);
break;
case "first_frame":
// Capturar start_frame_idx para sincronização no nível de 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":
// Salvar índices de frame para sincronização
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}`);
// Tentar iniciar reprodução sincronizada
tryStartStreamingPlayback();
break;
case "status":
log(data.message, "status");
break;
case "error":
log("Erro: " + data.message, "error");
stopStream();
break;
}
}
function handleFrame(frameData, frameIndex) {
// Criar blob diretamente dos bytes (sem base64!)
const blob = new Blob([frameData], { type: "image/jpeg" });
const url = URL.createObjectURL(blob);
// Acumular frames
allFrames.push({ url, index: frameIndex });
// Se já está reproduzindo (progressivo ou normal), adicionar ao buffer
if (progressivePlaybackStarted || playbackStarted) {
frameQueue.push({ url, index: frameIndex });
document.getElementById("buffer").textContent = frameQueue.length;
}
const received = allFrames.length;
document.getElementById("frames").textContent = received;
// Atualizar duracao do video (frames / FPS)
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...");
}
// Tentar iniciar playback streaming
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();
// Callback quando áudio termina
audioSource.onended = () => {
console.log("Audio terminou");
};
}
// REMOVIDO: startRendering() e renderNextFrame() obsoletos
// Agora usamos o render loop unificado (startUnifiedRenderLoop)
// que renderiza tanto frames do vídeo idle quanto frames do servidor
function handleAudioChunk(chunkData, chunkIndex, isLast) {
// Primeiro chunk - inicializar contexto
if (audioChunks.length === 0 && chunkData.length > 0) {
firstChunkTime = Date.now();
initAudioContext();
}
if (chunkData.length > 0) {
// Acumular chunk
audioChunks.push(chunkData);
// Acumular samples para calcular duração
const alignedBuffer = new ArrayBuffer(chunkData.length);
new Uint8Array(alignedBuffer).set(chunkData);
const samples = new Int16Array(alignedBuffer);
totalAudioSamples += samples.length;
// Atualizar duração na UI
const totalAudioDuration = totalAudioSamples / 24000;
document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
updateSyncDiff();
// === MODO PROGRESSIVO: Agendar chunk imediatamente ===
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}`);
}
// Tentar iniciar playback
tryStartStreamingPlayback();
}
function scheduleAudioChunkProgressive(chunkData) {
// Agendar este chunk de áudio para tocar em sequência
if (!audioContext) return;
try {
// Converter PCM int16 para float32
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;
}
// Criar buffer de audio
const buffer = audioContext.createBuffer(1, floatSamples.length, 24000);
buffer.getChannelData(0).set(floatSamples);
// Criar source
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
// Garantir que não agendamos no passado
const now = audioContext.currentTime;
if (nextAudioTime < now) {
nextAudioTime = now + 0.01;
}
// Agendar para tocar
source.start(nextAudioTime);
// Calcular duração e atualizar próximo tempo
const chunkDuration = floatSamples.length / 24000;
nextAudioTime += chunkDuration;
audioScheduledChunks++;
// Atualizar tempo esperado de fim (em performance.now)
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
});
}
// Inicializar o tempo de scheduling
nextAudioTime = audioContext.currentTime + 0.05; // 50ms de buffer inicial
audioScheduledChunks = 0;
}
function updateSyncDiff() {
// Calcular diferenca entre duracao do audio e do video
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";
// Colorir baseado na diferenca
if (Math.abs(diff) < 0.1) {
diffEl.style.color = "#0f0"; // Verde - sincronizado
} else if (Math.abs(diff) < 0.5) {
diffEl.style.color = "#fc0"; // Amarelo - pequena diferenca
} else {
diffEl.style.color = "#f00"; // Vermelho - dessincronizado
}
}
}
function scheduleAudioChunk(chunkData) {
if (!audioContext) return;
try {
// Converter PCM int16 para float32
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;
}
// Acumular samples para calcular duracao
totalAudioSamples += samples.length;
// Criar buffer de audio para este chunk
const chunkBuffer = audioContext.createBuffer(1, floatSamples.length, 24000);
chunkBuffer.getChannelData(0).set(floatSamples);
// Criar source e agendar
const source = audioContext.createBufferSource();
source.buffer = chunkBuffer;
source.connect(audioContext.destination);
// Garantir que não agendamos no passado
if (nextAudioTime < audioContext.currentTime) {
nextAudioTime = audioContext.currentTime + 0.01;
}
source.start(nextAudioTime);
// Atualizar próximo tempo
const chunkDuration = floatSamples.length / 24000;
nextAudioTime += chunkDuration;
audioScheduledChunks++;
// Atualizar duracao do audio na UI
const totalAudioDuration = totalAudioSamples / 24000;
document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
updateSyncDiff();
// Log do primeiro chunk
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) {
// Fallback para audio completo (modo antigo)
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() {
// Já iniciou playback final? Sair
if (playbackStarted) return;
const framesCount = allFrames.length;
const audioChunksCount = audioChunks.length;
// === MODO PROGRESSIVO ===
if (PROGRESSIVE_MODE && !progressivePlaybackStarted) {
// Verificar se temos buffer mínimo para começar
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 {
// Ainda buffering
const status = `Buffering: ${framesCount}/${MIN_FRAMES_TO_START} frames, ${audioChunksCount}/${MIN_AUDIO_CHUNKS_TO_START} chunks`;
document.getElementById("buffer").textContent = status;
return;
}
}
// === STREAM COMPLETO - Ajustar FPS final ===
if (streamDone && progressivePlaybackStarted && !playbackStarted) {
playbackStarted = true;
// Calcular FPS final baseado na duração real
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;
}
// === MODO TRADICIONAL (sem progressivo) ===
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() {
// Registrar latência
const playbackLatency = Date.now() - startTime;
document.getElementById("latency").textContent = playbackLatency + "ms";
log(`Latencia: ${playbackLatency}ms`, "status");
// Usar FPS padrão (25fps)
syncedFrameInterval = FRAME_INTERVAL;
console.log(`Iniciando progressivo: ${allFrames.length} frames, ${audioChunks.length} chunks`);
// Copiar frames disponíveis para a fila
frameQueue = [...allFrames];
document.getElementById("buffer").textContent = frameQueue.length;
// === SINCRONIZAÇÃO NO NÍVEL DE FRAME ===
// Se temos startFrameIdx, esperar o vídeo idle chegar no frame certo
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}...`);
// O render loop vai detectar waitingForFrameSync e fazer a transição quando chegar o frame certo
return;
}
// Sem frame sync - iniciar imediatamente
doStartSpeaking();
}
function doStartSpeaking() {
// Mudar para modo speaking
renderSource = 'speaking';
lastSpeakingRenderTime = performance.now();
// Iniciar áudio - agendar todos os chunks que já temos
startProgressiveAudio();
updateStatus("stream-status", "streaming", "Stream: Reproduzindo...");
}
function startProgressiveAudio() {
if (!audioContext) {
initAudioContext();
}
// Registrar tempo de início
audioPlaybackStartTime = performance.now();
audioContextStartTime = audioContext.currentTime;
nextAudioTime = audioContext.currentTime + 0.02; // 20ms de buffer inicial
// Agendar todos os chunks que já temos
for (const chunk of audioChunks) {
scheduleAudioChunkProgressive(chunk);
}
console.log(`Audio progressivo: ${audioScheduledChunks} chunks agendados`);
}
function restartAudioPlayback() {
// Parar qualquer áudio anterior e recomeçar do início
if (!audioContext) return;
// Criar buffer completo de todos os chunks
if (audioChunks.length === 0) return;
try {
// Parar o audio source anterior se existir
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;
}
// Converter PCM int16 para float32
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;
}
// Criar buffer e tocar
const buffer = audioContext.createBuffer(1, floatSamples.length, 24000);
buffer.getChannelData(0).set(floatSamples);
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
// Registrar tempo de início e fim esperado do áudio
audioPlaybackStartTime = performance.now();
audioExpectedEndTime = audioPlaybackStartTime + (buffer.duration * 1000);
// Callback quando áudio termina
source.onended = () => {
console.log("Audio playback ended (onended callback)");
// Segurança extra: finalizar playback se ainda não foi
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
});
}
// Se já temos audioBuffer pronto (modo completo), usar direto
if (audioBuffer) {
startAudioPlayback();
return;
}
// Caso contrário, juntar chunks disponíveis
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;
}
// Converter PCM int16 para float32
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; }
// Capturar o tempo COMPENSADO do vídeo idle (em ms)
// Considera a latência de rede para que o frame gerado corresponda
// ao frame que estará sendo exibido quando o vídeo chegar de volta
const idleVideoTimeMs = getCompensatedIdleVideoTime();
console.log(`Idle video compensated: ${idleVideoTimeMs}ms (latency: ${networkLatencyMs.toFixed(0)}ms)`);
stopStream();
// Reset frame state
frameQueue = [];
allFrames = [];
renderedFrames = 0;
totalBytes = 0;
startTime = Date.now();
// Reset audio/sync state
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;
// Reset frame sync state
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...");
// Pegar valores dos controles
const quality = parseInt(document.getElementById("quality-slider").value);
// Calcular start_frame_idx localmente (mesmo cálculo que o Wav2Lip faz)
// Isso permite sincronização imediata sem esperar resposta do servidor
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");
// Enviar com o timestamp do vídeo idle para sincronização
ws.send(JSON.stringify({
action: "generate",
text: text,
voice: voice,
idle_video_time_ms: idleVideoTimeMs, // Timestamp exato do vídeo idle
jpeg_quality: quality // Qualidade do JPEG (50-100)
}));
}
function stop() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: "stop" }));
}
stopStream();
}
function finishPlayback() {
// Evitar chamadas múltiplas
if (renderSource === 'idle') {
console.log("finishPlayback: já está em idle, ignorando");
return;
}
// Parar render interval antigo (se existir)
if (renderInterval) {
clearInterval(renderInterval);
renderInterval = null;
}
// Calcular estatísticas finais
const elapsed = (Date.now() - startTime) / 1000;
log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
// Transição para idle - calcular posição exata considerando tempo de rede
// O servidor envia end_video_time_ms = posição do último frame usado
// Precisamos compensar pelo tempo que passou desde que o servidor terminou até agora
if (endVideoTimeMs !== null && endVideoTimeMs > 0) {
const videoDuration = idleVideo.duration || 60;
const videoDurationMs = videoDuration * 1000;
// Calcular quanto tempo passou desde o início (tempo de processamento + rede)
const totalElapsedMs = Date.now() - startTime;
// O áudio tem a duração real - usar isso como referência
const audioDurationMs = totalAudioSamples / 24000 * 1000;
// Tempo extra que passou além da duração do áudio (overhead de rede/processamento)
const networkOverheadMs = Math.max(0, totalElapsedMs - audioDurationMs);
// Posição compensada: end_video_time_ms + overhead de rede
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;
}
// IMPORTANTE: Garantir que o vídeo idle está tocando (loop infinito)
idleVideo.loop = true;
idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
// Transição direta: speaking → idle (sem fade, mais natural)
renderSource = 'idle';
console.log("Transição direta para idle");
// Reset estado de audio
audioExpectedEndTime = 0;
audioPlaybackStartTime = 0;
// Reset estado geral
isStreaming = false;
playbackStarted = false;
progressivePlaybackStarted = false;
// Reset frame sync state (manter endVideoTimeMs até aqui pois é usado acima)
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;
}
// Liberar URLs pendentes
frameQueue.forEach(f => URL.revokeObjectURL(f.url));
frameQueue = [];
// Liberar URLs acumulados
allFrames.forEach(f => URL.revokeObjectURL(f.url));
allFrames = [];
// Reset sync state
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;
}
// Reset frame sync state
startFrameIdx = null;
endFrameIdx = null;
endVideoTimeMs = null;
waitingForFrameSync = false;
// Voltar ao video idle (apenas muda a fonte do render loop)
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");
}
// Heartbeat
setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: "ping" }));
}
}, 30000);
// === MODO DEMO ===
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");
// Iniciar primeiro 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...");
// Usar o texto configurado
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();
// Reset estado
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;
// Reset frame sync state
endFrameIdx = null;
endVideoTimeMs = null;
waitingForFrameSync = false;
// Calcular start_frame_idx localmente (mesmo cálculo que o Wav2Lip faz)
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);
}
// Modificar finishPlayback para suportar demo mode
const originalFinishPlayback = finishPlayback;
finishPlayback = function() {
originalFinishPlayback();
// Se está em modo demo, agendar próximo ciclo
if (demoMode) {
demoScheduleNextCycle();
}
};
connectWebSocket();
</script>
</body>
</html>