Commit ·
a46605e
1
Parent(s): 3acaae2
Fix audio-video sync: stop lip-sync when audio ends and return to idle loop
Browse files- Add audio playback timing tracking (audioExpectedEndTime, audioPlaybackStartTime)
- Stop video rendering immediately when audio duration is reached
- Fix finishPlayback() to ensure idle video resumes in infinite loop
- Add guard against multiple finishPlayback() calls
- Reset audio timing state variables on playback start and stop
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- interface/index_optimized.html +326 -114
interface/index_optimized.html
CHANGED
|
@@ -55,24 +55,20 @@
|
|
| 55 |
background: #000; border-radius: 10px; overflow: hidden;
|
| 56 |
position: relative;
|
| 57 |
}
|
|
|
|
| 58 |
#idle-video {
|
| 59 |
position: absolute; top: 0; left: 0;
|
| 60 |
-
width:
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
transition: opacity 0.2s ease;
|
| 64 |
}
|
| 65 |
-
|
| 66 |
#avatar-canvas {
|
| 67 |
position: absolute; top: 0; left: 0;
|
| 68 |
width: 100%; height: 100%;
|
| 69 |
object-fit: contain;
|
| 70 |
-
|
| 71 |
-
pointer-events: none;
|
| 72 |
-
z-index: 0;
|
| 73 |
-
transition: opacity 0.2s ease;
|
| 74 |
}
|
| 75 |
-
#avatar-canvas.active { opacity: 1; pointer-events: auto; z-index: 2; }
|
| 76 |
|
| 77 |
.control-section {
|
| 78 |
flex: 1; min-width: 280px;
|
|
@@ -215,7 +211,7 @@
|
|
| 215 |
</div>
|
| 216 |
|
| 217 |
<script>
|
| 218 |
-
const WS_URL = "ws://
|
| 219 |
const TARGET_FPS = 25;
|
| 220 |
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
| 221 |
|
|
@@ -252,18 +248,120 @@
|
|
| 252 |
let audioScheduledChunks = 0; // Quantos chunks já agendamos
|
| 253 |
let firstChunkTime = null; // Tempo do primeiro chunk (para latência)
|
| 254 |
let totalAudioSamples = 0; // Total de samples de audio recebidos
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
// Sincronização de transição
|
| 257 |
let endVideoTimeMs = null; // Tempo para continuar o vídeo idle após fala
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
// Canvas e Video Idle
|
| 260 |
const canvas = document.getElementById("avatar-canvas");
|
| 261 |
const ctx = canvas.getContext("2d");
|
| 262 |
const idleVideo = document.getElementById("idle-video");
|
| 263 |
|
| 264 |
-
// Iniciar video idle
|
| 265 |
idleVideo.play().catch(e => console.log("Autoplay blocked:", e));
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
function log(msg, type = "msg") {
|
| 268 |
const logDiv = document.getElementById("log");
|
| 269 |
const time = new Date().toLocaleTimeString();
|
|
@@ -287,6 +385,8 @@
|
|
| 287 |
ws.onopen = () => {
|
| 288 |
updateStatus("ws-status", "online", "WS: Conectado");
|
| 289 |
log("Conectado (binary mode)", "msg");
|
|
|
|
|
|
|
| 290 |
};
|
| 291 |
|
| 292 |
ws.onmessage = (event) => {
|
|
@@ -312,6 +412,51 @@
|
|
| 312 |
ws.onerror = () => log("Erro WebSocket", "error");
|
| 313 |
}
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
function handleBinaryMessage(buffer) {
|
| 316 |
const view = new DataView(buffer);
|
| 317 |
const msgType = view.getUint8(0);
|
|
@@ -354,6 +499,11 @@
|
|
| 354 |
|
| 355 |
function handleJsonMessage(data) {
|
| 356 |
switch (data.type) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
case "first_frame":
|
| 358 |
// Apenas log - latência real é calculada no início do playback
|
| 359 |
log(`Primeiro frame (server): ${data.latency_ms}ms`, "status");
|
|
@@ -413,12 +563,10 @@
|
|
| 413 |
}
|
| 414 |
|
| 415 |
if (received === 1) {
|
| 416 |
-
//
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
idleVideo.pause();
|
| 421 |
-
updateStatus("stream-status", "streaming", "Stream: Recebendo...");
|
| 422 |
}
|
| 423 |
|
| 424 |
// Tentar iniciar playback streaming
|
|
@@ -443,54 +591,9 @@
|
|
| 443 |
};
|
| 444 |
}
|
| 445 |
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
// Usar intervalo sincronizado com áudio
|
| 451 |
-
renderInterval = setInterval(() => {
|
| 452 |
-
renderNextFrame();
|
| 453 |
-
}, syncedFrameInterval);
|
| 454 |
-
}
|
| 455 |
-
|
| 456 |
-
function renderNextFrame() {
|
| 457 |
-
if (frameQueue.length === 0) {
|
| 458 |
-
document.getElementById("buffer").textContent = "0";
|
| 459 |
-
// Só finalizar se o stream terminou E o buffer está vazio
|
| 460 |
-
if (streamDone) {
|
| 461 |
-
finishPlayback();
|
| 462 |
-
}
|
| 463 |
-
// Caso contrário, aguardar mais frames
|
| 464 |
-
return;
|
| 465 |
-
}
|
| 466 |
-
|
| 467 |
-
const frame = frameQueue.shift();
|
| 468 |
-
document.getElementById("buffer").textContent = frameQueue.length;
|
| 469 |
-
|
| 470 |
-
const img = new Image();
|
| 471 |
-
img.onload = () => {
|
| 472 |
-
if (canvas.width !== img.width || canvas.height !== img.height) {
|
| 473 |
-
canvas.width = img.width;
|
| 474 |
-
canvas.height = img.height;
|
| 475 |
-
}
|
| 476 |
-
ctx.drawImage(img, 0, 0);
|
| 477 |
-
URL.revokeObjectURL(frame.url); // Liberar memória
|
| 478 |
-
renderedFrames++;
|
| 479 |
-
document.getElementById("rendered").textContent = renderedFrames;
|
| 480 |
-
};
|
| 481 |
-
img.src = frame.url;
|
| 482 |
-
|
| 483 |
-
// FPS real
|
| 484 |
-
const now = performance.now();
|
| 485 |
-
const fps = 1000 / (now - lastRenderTime);
|
| 486 |
-
lastRenderTime = now;
|
| 487 |
-
updateStatus("fps-status", "", `FPS: ${fps.toFixed(0)}`);
|
| 488 |
-
|
| 489 |
-
// Bandwidth
|
| 490 |
-
const elapsed = (Date.now() - startTime) / 1000;
|
| 491 |
-
const bw = elapsed > 0 ? (totalBytes / 1024 / elapsed) : 0;
|
| 492 |
-
updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`);
|
| 493 |
-
}
|
| 494 |
|
| 495 |
function handleAudioChunk(chunkData, chunkIndex, isLast) {
|
| 496 |
// Primeiro chunk - inicializar contexto
|
|
@@ -499,13 +602,22 @@
|
|
| 499 |
initAudioContext();
|
| 500 |
}
|
| 501 |
|
| 502 |
-
// Acumular chunk
|
| 503 |
if (chunkData.length > 0) {
|
| 504 |
audioChunks.push(chunkData);
|
| 505 |
-
console.log(`Audio chunk ${chunkIndex}: ${chunkData.length} bytes, total=${audioChunks.length}`);
|
| 506 |
|
| 507 |
-
//
|
| 508 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 509 |
}
|
| 510 |
|
| 511 |
if (isLast) {
|
|
@@ -641,16 +753,22 @@
|
|
| 641 |
// 1. Já iniciou? Sair
|
| 642 |
if (playbackStarted) return;
|
| 643 |
|
| 644 |
-
// 2.
|
| 645 |
-
|
| 646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
return;
|
| 648 |
}
|
| 649 |
|
| 650 |
-
//
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
console.log(`Aguardando mais audio: ${audioScheduledChunks} chunks agendados...`);
|
| 654 |
return;
|
| 655 |
}
|
| 656 |
|
|
@@ -662,20 +780,93 @@
|
|
| 662 |
document.getElementById("latency").textContent = playbackLatency + "ms";
|
| 663 |
log(`Latencia: ${playbackLatency}ms`, "status");
|
| 664 |
|
| 665 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
console.log(`Latencia: ${playbackLatency}ms`);
|
| 667 |
-
console.log(`Frames
|
| 668 |
-
console.log(`Audio
|
|
|
|
| 669 |
|
| 670 |
-
// Iniciar renderização de frames
|
| 671 |
-
// O áudio já está tocando via scheduling de chunks
|
| 672 |
frameQueue = [...allFrames];
|
| 673 |
document.getElementById("buffer").textContent = frameQueue.length;
|
| 674 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
|
| 676 |
updateStatus("stream-status", "streaming", "Stream: Reproduzindo");
|
| 677 |
}
|
| 678 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
async function prepareAndPlayAudio() {
|
| 680 |
try {
|
| 681 |
if (!audioContext) {
|
|
@@ -731,10 +922,11 @@
|
|
| 731 |
if (!text) { log("Digite texto", "error"); return; }
|
| 732 |
if (!ws || ws.readyState !== WebSocket.OPEN) { log("WS desconectado", "error"); return; }
|
| 733 |
|
| 734 |
-
// Capturar o tempo
|
| 735 |
-
//
|
| 736 |
-
|
| 737 |
-
|
|
|
|
| 738 |
|
| 739 |
stopStream();
|
| 740 |
|
|
@@ -757,6 +949,9 @@
|
|
| 757 |
audioScheduledChunks = 0;
|
| 758 |
firstChunkTime = null;
|
| 759 |
totalAudioSamples = 0;
|
|
|
|
|
|
|
|
|
|
| 760 |
|
| 761 |
document.getElementById("frames").textContent = "0";
|
| 762 |
document.getElementById("rendered").textContent = "0";
|
|
@@ -792,7 +987,13 @@
|
|
| 792 |
}
|
| 793 |
|
| 794 |
function finishPlayback() {
|
| 795 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
if (renderInterval) {
|
| 797 |
clearInterval(renderInterval);
|
| 798 |
renderInterval = null;
|
|
@@ -803,31 +1004,35 @@
|
|
| 803 |
log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
|
| 804 |
console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
|
| 805 |
|
| 806 |
-
//
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
idleVideo.
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
idleVideo.currentTime = seekTime;
|
| 820 |
-
}
|
| 821 |
|
| 822 |
-
|
|
|
|
|
|
|
| 823 |
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
}
|
| 832 |
|
| 833 |
function stopStream() {
|
|
@@ -857,16 +1062,23 @@
|
|
| 857 |
audioScheduledChunks = 0;
|
| 858 |
firstChunkTime = null;
|
| 859 |
totalAudioSamples = 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
|
| 861 |
if (audioSource) {
|
| 862 |
try { audioSource.stop(); } catch (e) {}
|
| 863 |
audioSource = null;
|
| 864 |
}
|
| 865 |
|
| 866 |
-
// Voltar ao video idle
|
| 867 |
-
|
| 868 |
-
idleVideo.classList.remove("hidden");
|
| 869 |
idleVideo.play().catch(e => {});
|
|
|
|
| 870 |
|
| 871 |
document.getElementById("generate-btn").disabled = false;
|
| 872 |
document.getElementById("stop-btn").disabled = true;
|
|
|
|
| 55 |
background: #000; border-radius: 10px; overflow: hidden;
|
| 56 |
position: relative;
|
| 57 |
}
|
| 58 |
+
/* Video idle fica oculto - só é usado como fonte para o canvas */
|
| 59 |
#idle-video {
|
| 60 |
position: absolute; top: 0; left: 0;
|
| 61 |
+
width: 1px; height: 1px;
|
| 62 |
+
opacity: 0;
|
| 63 |
+
pointer-events: none;
|
|
|
|
| 64 |
}
|
| 65 |
+
/* Canvas sempre visível - renderiza tanto idle quanto speaking */
|
| 66 |
#avatar-canvas {
|
| 67 |
position: absolute; top: 0; left: 0;
|
| 68 |
width: 100%; height: 100%;
|
| 69 |
object-fit: contain;
|
| 70 |
+
display: block;
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
|
|
|
| 72 |
|
| 73 |
.control-section {
|
| 74 |
flex: 1; min-width: 280px;
|
|
|
|
| 211 |
</div>
|
| 212 |
|
| 213 |
<script>
|
| 214 |
+
const WS_URL = "ws://localhost:8080/ws";
|
| 215 |
const TARGET_FPS = 25;
|
| 216 |
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
| 217 |
|
|
|
|
| 248 |
let audioScheduledChunks = 0; // Quantos chunks já agendamos
|
| 249 |
let firstChunkTime = null; // Tempo do primeiro chunk (para latência)
|
| 250 |
let totalAudioSamples = 0; // Total de samples de audio recebidos
|
| 251 |
+
let currentAudioSource = null; // Referência ao audio source atual para detectar quando termina
|
| 252 |
+
let audioPlaybackStartTime = 0; // Tempo em que o audio começou a tocar
|
| 253 |
+
let audioExpectedEndTime = 0; // Tempo esperado para o audio terminar
|
| 254 |
|
| 255 |
// Sincronização de transição
|
| 256 |
let endVideoTimeMs = null; // Tempo para continuar o vídeo idle após fala
|
| 257 |
|
| 258 |
+
// Renderização unificada no canvas
|
| 259 |
+
let renderSource = 'idle'; // 'idle' = video, 'speaking' = frames do servidor
|
| 260 |
+
let unifiedRenderLoop = null; // ID do requestAnimationFrame
|
| 261 |
+
|
| 262 |
+
// Medição de latência de rede
|
| 263 |
+
let networkLatencyMs = 100; // Latência estimada (RTT/2), começa com valor default
|
| 264 |
+
let lastPingSentAt = null; // Timestamp do último ping
|
| 265 |
+
let latencyHistory = []; // Histórico de latências para média móvel
|
| 266 |
+
const LATENCY_SAMPLES = 5; // Quantidade de amostras para média
|
| 267 |
+
const IDLE_VIDEO_FPS = 25; // FPS do vídeo idle
|
| 268 |
+
|
| 269 |
// Canvas e Video Idle
|
| 270 |
const canvas = document.getElementById("avatar-canvas");
|
| 271 |
const ctx = canvas.getContext("2d");
|
| 272 |
const idleVideo = document.getElementById("idle-video");
|
| 273 |
|
| 274 |
+
// Iniciar video idle (escondido - só o canvas aparece)
|
| 275 |
idleVideo.play().catch(e => console.log("Autoplay blocked:", e));
|
| 276 |
|
| 277 |
+
// Render loop unificado - sempre desenha no canvas
|
| 278 |
+
// Quando idle: desenha frame do vídeo
|
| 279 |
+
// Quando speaking: desenha frames recebidos do servidor
|
| 280 |
+
let speakingFrameIndex = 0;
|
| 281 |
+
let lastSpeakingRenderTime = 0;
|
| 282 |
+
|
| 283 |
+
function startUnifiedRenderLoop() {
|
| 284 |
+
if (unifiedRenderLoop) return; // Já está rodando
|
| 285 |
+
|
| 286 |
+
function renderFrame() {
|
| 287 |
+
if (renderSource === 'idle') {
|
| 288 |
+
// Desenha o frame atual do vídeo idle no canvas
|
| 289 |
+
if (idleVideo.readyState >= 2) { // HAVE_CURRENT_DATA
|
| 290 |
+
ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
|
| 291 |
+
}
|
| 292 |
+
} else if (renderSource === 'speaking') {
|
| 293 |
+
// Desenha frames do servidor com timing controlado
|
| 294 |
+
const now = performance.now();
|
| 295 |
+
const elapsed = now - lastSpeakingRenderTime;
|
| 296 |
+
|
| 297 |
+
// SINCRONIZAÇÃO: Verificar se o áudio já terminou
|
| 298 |
+
// Se o tempo esperado de fim do áudio já passou, parar de renderizar frames
|
| 299 |
+
if (audioExpectedEndTime > 0 && now >= audioExpectedEndTime) {
|
| 300 |
+
console.log(`Audio terminou (${now.toFixed(0)} >= ${audioExpectedEndTime.toFixed(0)}), finalizando video`);
|
| 301 |
+
finishPlayback();
|
| 302 |
+
return;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
if (elapsed >= syncedFrameInterval && frameQueue.length > 0) {
|
| 306 |
+
const frameData = frameQueue.shift();
|
| 307 |
+
|
| 308 |
+
// Carregar imagem a partir da URL
|
| 309 |
+
const img = new Image();
|
| 310 |
+
img.onload = () => {
|
| 311 |
+
// Ajustar canvas se necessário
|
| 312 |
+
if (canvas.width !== img.width || canvas.height !== img.height) {
|
| 313 |
+
canvas.width = img.width;
|
| 314 |
+
canvas.height = img.height;
|
| 315 |
+
}
|
| 316 |
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
| 317 |
+
URL.revokeObjectURL(frameData.url); // Liberar memória
|
| 318 |
+
};
|
| 319 |
+
img.src = frameData.url;
|
| 320 |
+
|
| 321 |
+
renderedFrames++;
|
| 322 |
+
document.getElementById("rendered").textContent = renderedFrames;
|
| 323 |
+
document.getElementById("buffer").textContent = frameQueue.length;
|
| 324 |
+
lastSpeakingRenderTime = now;
|
| 325 |
+
|
| 326 |
+
// Atualizar FPS
|
| 327 |
+
const fps = 1000 / elapsed;
|
| 328 |
+
updateStatus("fps-status", "", `FPS: ${fps.toFixed(0)}`);
|
| 329 |
+
|
| 330 |
+
// Bandwidth
|
| 331 |
+
const totalElapsed = (Date.now() - startTime) / 1000;
|
| 332 |
+
const bw = totalElapsed > 0 ? (totalBytes / 1024 / totalElapsed) : 0;
|
| 333 |
+
updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`);
|
| 334 |
+
|
| 335 |
+
// Verificar se terminou
|
| 336 |
+
if (streamDone && frameQueue.length === 0) {
|
| 337 |
+
finishPlayback();
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
unifiedRenderLoop = requestAnimationFrame(renderFrame);
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
unifiedRenderLoop = requestAnimationFrame(renderFrame);
|
| 346 |
+
console.log("Unified render loop started");
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function stopUnifiedRenderLoop() {
|
| 350 |
+
if (unifiedRenderLoop) {
|
| 351 |
+
cancelAnimationFrame(unifiedRenderLoop);
|
| 352 |
+
unifiedRenderLoop = null;
|
| 353 |
+
console.log("Unified render loop stopped");
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// Iniciar o render loop quando a página carrega
|
| 358 |
+
idleVideo.addEventListener('loadeddata', () => {
|
| 359 |
+
// Ajustar tamanho do canvas para match do vídeo
|
| 360 |
+
canvas.width = idleVideo.videoWidth || 512;
|
| 361 |
+
canvas.height = idleVideo.videoHeight || 512;
|
| 362 |
+
startUnifiedRenderLoop();
|
| 363 |
+
});
|
| 364 |
+
|
| 365 |
function log(msg, type = "msg") {
|
| 366 |
const logDiv = document.getElementById("log");
|
| 367 |
const time = new Date().toLocaleTimeString();
|
|
|
|
| 385 |
ws.onopen = () => {
|
| 386 |
updateStatus("ws-status", "online", "WS: Conectado");
|
| 387 |
log("Conectado (binary mode)", "msg");
|
| 388 |
+
// Iniciar medição de latência
|
| 389 |
+
measureLatency();
|
| 390 |
};
|
| 391 |
|
| 392 |
ws.onmessage = (event) => {
|
|
|
|
| 412 |
ws.onerror = () => log("Erro WebSocket", "error");
|
| 413 |
}
|
| 414 |
|
| 415 |
+
// Medir latência de rede (RTT)
|
| 416 |
+
function measureLatency() {
|
| 417 |
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
| 418 |
+
|
| 419 |
+
lastPingSentAt = performance.now();
|
| 420 |
+
ws.send(JSON.stringify({ action: "ping", timestamp: lastPingSentAt }));
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
// Processar resposta de latência
|
| 424 |
+
function handlePong(serverTimestamp) {
|
| 425 |
+
if (!lastPingSentAt) return;
|
| 426 |
+
|
| 427 |
+
const rtt = performance.now() - lastPingSentAt;
|
| 428 |
+
const oneWayLatency = rtt / 2;
|
| 429 |
+
|
| 430 |
+
// Adicionar ao histórico
|
| 431 |
+
latencyHistory.push(oneWayLatency);
|
| 432 |
+
if (latencyHistory.length > LATENCY_SAMPLES) {
|
| 433 |
+
latencyHistory.shift();
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
// Calcular média móvel
|
| 437 |
+
networkLatencyMs = latencyHistory.reduce((a, b) => a + b, 0) / latencyHistory.length;
|
| 438 |
+
|
| 439 |
+
console.log(`Latência: RTT=${rtt.toFixed(0)}ms, one-way=${networkLatencyMs.toFixed(0)}ms`);
|
| 440 |
+
|
| 441 |
+
// Agendar próxima medição (a cada 5 segundos)
|
| 442 |
+
setTimeout(measureLatency, 5000);
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
// Calcular timestamp compensado para gerar vídeo no frame certo
|
| 446 |
+
function getCompensatedIdleVideoTime() {
|
| 447 |
+
const currentTimeMs = idleVideo.currentTime * 1000;
|
| 448 |
+
const videoDurationMs = (idleVideo.duration || 60) * 1000;
|
| 449 |
+
|
| 450 |
+
// Compensar: tempo atual + latência de ida + tempo estimado de processamento
|
| 451 |
+
// O servidor vai receber o request depois de networkLatencyMs
|
| 452 |
+
// E vai começar a gerar o vídeo, que precisa casar com o frame nesse momento
|
| 453 |
+
const processingEstimateMs = 50; // Estimativa de overhead de processamento
|
| 454 |
+
const compensatedTime = currentTimeMs + networkLatencyMs + processingEstimateMs;
|
| 455 |
+
|
| 456 |
+
// Garantir que está dentro do range do vídeo (loop)
|
| 457 |
+
return Math.round(compensatedTime % videoDurationMs);
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
function handleBinaryMessage(buffer) {
|
| 461 |
const view = new DataView(buffer);
|
| 462 |
const msgType = view.getUint8(0);
|
|
|
|
| 499 |
|
| 500 |
function handleJsonMessage(data) {
|
| 501 |
switch (data.type) {
|
| 502 |
+
case "pong":
|
| 503 |
+
// Resposta do ping - calcular latência
|
| 504 |
+
handlePong(data.timestamp);
|
| 505 |
+
break;
|
| 506 |
+
|
| 507 |
case "first_frame":
|
| 508 |
// Apenas log - latência real é calculada no início do playback
|
| 509 |
log(`Primeiro frame (server): ${data.latency_ms}ms`, "status");
|
|
|
|
| 563 |
}
|
| 564 |
|
| 565 |
if (received === 1) {
|
| 566 |
+
// Primeiro frame - apenas log, NÃO mudar para speaking ainda
|
| 567 |
+
// (vai mudar quando playback sincronizado começar)
|
| 568 |
+
console.log("Primeiro frame recebido - buffering...");
|
| 569 |
+
updateStatus("stream-status", "streaming", "Stream: Buffering...");
|
|
|
|
|
|
|
| 570 |
}
|
| 571 |
|
| 572 |
// Tentar iniciar playback streaming
|
|
|
|
| 591 |
};
|
| 592 |
}
|
| 593 |
|
| 594 |
+
// REMOVIDO: startRendering() e renderNextFrame() obsoletos
|
| 595 |
+
// Agora usamos o render loop unificado (startUnifiedRenderLoop)
|
| 596 |
+
// que renderiza tanto frames do vídeo idle quanto frames do servidor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
|
| 598 |
function handleAudioChunk(chunkData, chunkIndex, isLast) {
|
| 599 |
// Primeiro chunk - inicializar contexto
|
|
|
|
| 602 |
initAudioContext();
|
| 603 |
}
|
| 604 |
|
| 605 |
+
// Acumular chunk (NÃO tocar ainda - esperar sync)
|
| 606 |
if (chunkData.length > 0) {
|
| 607 |
audioChunks.push(chunkData);
|
|
|
|
| 608 |
|
| 609 |
+
// Acumular samples para calcular duração
|
| 610 |
+
const alignedBuffer = new ArrayBuffer(chunkData.length);
|
| 611 |
+
new Uint8Array(alignedBuffer).set(chunkData);
|
| 612 |
+
const samples = new Int16Array(alignedBuffer);
|
| 613 |
+
totalAudioSamples += samples.length;
|
| 614 |
+
|
| 615 |
+
// Atualizar duração na UI
|
| 616 |
+
const totalAudioDuration = totalAudioSamples / 24000;
|
| 617 |
+
document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
|
| 618 |
+
updateSyncDiff();
|
| 619 |
+
|
| 620 |
+
console.log(`Audio chunk ${chunkIndex}: ${chunkData.length} bytes, total=${audioChunks.length}, duration=${totalAudioDuration.toFixed(2)}s`);
|
| 621 |
}
|
| 622 |
|
| 623 |
if (isLast) {
|
|
|
|
| 753 |
// 1. Já iniciou? Sair
|
| 754 |
if (playbackStarted) return;
|
| 755 |
|
| 756 |
+
// 2. Esperar stream completo (todos frames e áudio)
|
| 757 |
+
// Isso permite sincronização perfeita entre áudio e vídeo
|
| 758 |
+
if (!streamDone) {
|
| 759 |
+
console.log(`Aguardando stream completo...`);
|
| 760 |
+
return;
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
// 3. Temos frames?
|
| 764 |
+
if (allFrames.length === 0) {
|
| 765 |
+
console.log(`Nenhum frame recebido`);
|
| 766 |
return;
|
| 767 |
}
|
| 768 |
|
| 769 |
+
// 4. Temos áudio?
|
| 770 |
+
if (totalAudioSamples === 0) {
|
| 771 |
+
console.log(`Nenhum áudio recebido`);
|
|
|
|
| 772 |
return;
|
| 773 |
}
|
| 774 |
|
|
|
|
| 780 |
document.getElementById("latency").textContent = playbackLatency + "ms";
|
| 781 |
log(`Latencia: ${playbackLatency}ms`, "status");
|
| 782 |
|
| 783 |
+
// *** SINCRONIZAÇÃO: Calcular FPS baseado na duração real do áudio ***
|
| 784 |
+
const audioDurationSec = totalAudioSamples / 24000;
|
| 785 |
+
const totalFrames = allFrames.length;
|
| 786 |
+
const calculatedFps = totalFrames / audioDurationSec;
|
| 787 |
+
syncedFrameInterval = 1000 / calculatedFps;
|
| 788 |
+
|
| 789 |
+
console.log(`=== INICIANDO PLAYBACK SINCRONIZADO ===`);
|
| 790 |
console.log(`Latencia: ${playbackLatency}ms`);
|
| 791 |
+
console.log(`Frames: ${totalFrames}`);
|
| 792 |
+
console.log(`Audio: ${audioDurationSec.toFixed(2)}s (${totalAudioSamples} samples)`);
|
| 793 |
+
console.log(`FPS calculado: ${calculatedFps.toFixed(2)} (interval: ${syncedFrameInterval.toFixed(1)}ms)`);
|
| 794 |
|
| 795 |
+
// Iniciar renderização de frames via render loop unificado
|
|
|
|
| 796 |
frameQueue = [...allFrames];
|
| 797 |
document.getElementById("buffer").textContent = frameQueue.length;
|
| 798 |
+
renderSource = 'speaking';
|
| 799 |
+
lastSpeakingRenderTime = performance.now();
|
| 800 |
+
|
| 801 |
+
// Reiniciar o áudio do início para sincronizar com os frames
|
| 802 |
+
// (os chunks já foram agendados, precisamos recomeçar)
|
| 803 |
+
restartAudioPlayback();
|
| 804 |
|
| 805 |
updateStatus("stream-status", "streaming", "Stream: Reproduzindo");
|
| 806 |
}
|
| 807 |
|
| 808 |
+
function restartAudioPlayback() {
|
| 809 |
+
// Parar qualquer áudio anterior e recomeçar do início
|
| 810 |
+
if (!audioContext) return;
|
| 811 |
+
|
| 812 |
+
// Criar buffer completo de todos os chunks
|
| 813 |
+
if (audioChunks.length === 0) return;
|
| 814 |
+
|
| 815 |
+
try {
|
| 816 |
+
// Parar o audio source anterior se existir
|
| 817 |
+
if (currentAudioSource) {
|
| 818 |
+
try { currentAudioSource.stop(); } catch (e) {}
|
| 819 |
+
currentAudioSource = null;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
const totalLength = audioChunks.reduce((sum, c) => sum + c.length, 0);
|
| 823 |
+
const combined = new Uint8Array(totalLength);
|
| 824 |
+
let offset = 0;
|
| 825 |
+
for (const chunk of audioChunks) {
|
| 826 |
+
combined.set(chunk, offset);
|
| 827 |
+
offset += chunk.length;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
// Converter PCM int16 para float32
|
| 831 |
+
const alignedBuffer = new ArrayBuffer(combined.length);
|
| 832 |
+
new Uint8Array(alignedBuffer).set(combined);
|
| 833 |
+
const samples = new Int16Array(alignedBuffer);
|
| 834 |
+
const floatSamples = new Float32Array(samples.length);
|
| 835 |
+
for (let i = 0; i < samples.length; i++) {
|
| 836 |
+
floatSamples[i] = samples[i] / 32768;
|
| 837 |
+
}
|
| 838 |
+
|
| 839 |
+
// Criar buffer e tocar
|
| 840 |
+
const buffer = audioContext.createBuffer(1, floatSamples.length, 24000);
|
| 841 |
+
buffer.getChannelData(0).set(floatSamples);
|
| 842 |
+
|
| 843 |
+
const source = audioContext.createBufferSource();
|
| 844 |
+
source.buffer = buffer;
|
| 845 |
+
source.connect(audioContext.destination);
|
| 846 |
+
|
| 847 |
+
// Registrar tempo de início e fim esperado do áudio
|
| 848 |
+
audioPlaybackStartTime = performance.now();
|
| 849 |
+
audioExpectedEndTime = audioPlaybackStartTime + (buffer.duration * 1000);
|
| 850 |
+
|
| 851 |
+
// Callback quando áudio termina
|
| 852 |
+
source.onended = () => {
|
| 853 |
+
console.log("Audio playback ended (onended callback)");
|
| 854 |
+
// Segurança extra: finalizar playback se ainda não foi
|
| 855 |
+
if (renderSource === 'speaking') {
|
| 856 |
+
finishPlayback();
|
| 857 |
+
}
|
| 858 |
+
};
|
| 859 |
+
|
| 860 |
+
source.start();
|
| 861 |
+
currentAudioSource = source;
|
| 862 |
+
|
| 863 |
+
console.log(`Audio reiniciado: ${buffer.duration.toFixed(2)}s, end expected at ${audioExpectedEndTime.toFixed(0)}ms`);
|
| 864 |
+
|
| 865 |
+
} catch (e) {
|
| 866 |
+
console.error("Erro reiniciando audio:", e);
|
| 867 |
+
}
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
async function prepareAndPlayAudio() {
|
| 871 |
try {
|
| 872 |
if (!audioContext) {
|
|
|
|
| 922 |
if (!text) { log("Digite texto", "error"); return; }
|
| 923 |
if (!ws || ws.readyState !== WebSocket.OPEN) { log("WS desconectado", "error"); return; }
|
| 924 |
|
| 925 |
+
// Capturar o tempo COMPENSADO do vídeo idle (em ms)
|
| 926 |
+
// Considera a latência de rede para que o frame gerado corresponda
|
| 927 |
+
// ao frame que estará sendo exibido quando o vídeo chegar de volta
|
| 928 |
+
const idleVideoTimeMs = getCompensatedIdleVideoTime();
|
| 929 |
+
console.log(`Idle video compensated: ${idleVideoTimeMs}ms (latency: ${networkLatencyMs.toFixed(0)}ms)`);
|
| 930 |
|
| 931 |
stopStream();
|
| 932 |
|
|
|
|
| 949 |
audioScheduledChunks = 0;
|
| 950 |
firstChunkTime = null;
|
| 951 |
totalAudioSamples = 0;
|
| 952 |
+
currentAudioSource = null;
|
| 953 |
+
audioPlaybackStartTime = 0;
|
| 954 |
+
audioExpectedEndTime = 0;
|
| 955 |
|
| 956 |
document.getElementById("frames").textContent = "0";
|
| 957 |
document.getElementById("rendered").textContent = "0";
|
|
|
|
| 987 |
}
|
| 988 |
|
| 989 |
function finishPlayback() {
|
| 990 |
+
// Evitar chamadas múltiplas
|
| 991 |
+
if (renderSource === 'idle') {
|
| 992 |
+
console.log("finishPlayback: já está em idle, ignorando");
|
| 993 |
+
return;
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
// Parar render interval antigo (se existir)
|
| 997 |
if (renderInterval) {
|
| 998 |
clearInterval(renderInterval);
|
| 999 |
renderInterval = null;
|
|
|
|
| 1004 |
log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
|
| 1005 |
console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
|
| 1006 |
|
| 1007 |
+
// Transição IMEDIATA para idle (não precisa esperar)
|
| 1008 |
+
// Se servidor enviou end_video_time_ms, continuar o vídeo desse ponto
|
| 1009 |
+
if (endVideoTimeMs !== null) {
|
| 1010 |
+
const videoTimeSeconds = endVideoTimeMs / 1000;
|
| 1011 |
+
const videoDuration = idleVideo.duration || 60;
|
| 1012 |
+
const seekTime = videoTimeSeconds % videoDuration;
|
| 1013 |
+
console.log(`Transição suave: vídeo idle em ${seekTime.toFixed(2)}s (end_video_time_ms=${endVideoTimeMs})`);
|
| 1014 |
+
idleVideo.currentTime = seekTime;
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
// IMPORTANTE: Garantir que o vídeo idle está tocando (loop infinito)
|
| 1018 |
+
idleVideo.loop = true;
|
| 1019 |
+
idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
|
|
|
|
|
|
|
| 1020 |
|
| 1021 |
+
// Transição: speaking → idle (só muda a fonte, canvas continua renderizando)
|
| 1022 |
+
renderSource = 'idle';
|
| 1023 |
+
console.log("Transição para idle - renderSource mudou, idle video playing");
|
| 1024 |
|
| 1025 |
+
// Reset estado de audio
|
| 1026 |
+
audioExpectedEndTime = 0;
|
| 1027 |
+
audioPlaybackStartTime = 0;
|
| 1028 |
+
|
| 1029 |
+
// Reset estado geral
|
| 1030 |
+
isStreaming = false;
|
| 1031 |
+
endVideoTimeMs = null;
|
| 1032 |
+
playbackStarted = false;
|
| 1033 |
+
document.getElementById("generate-btn").disabled = false;
|
| 1034 |
+
document.getElementById("stop-btn").disabled = true;
|
| 1035 |
+
updateStatus("stream-status", "online", "Stream: Concluido");
|
| 1036 |
}
|
| 1037 |
|
| 1038 |
function stopStream() {
|
|
|
|
| 1062 |
audioScheduledChunks = 0;
|
| 1063 |
firstChunkTime = null;
|
| 1064 |
totalAudioSamples = 0;
|
| 1065 |
+
audioPlaybackStartTime = 0;
|
| 1066 |
+
audioExpectedEndTime = 0;
|
| 1067 |
+
|
| 1068 |
+
if (currentAudioSource) {
|
| 1069 |
+
try { currentAudioSource.stop(); } catch (e) {}
|
| 1070 |
+
currentAudioSource = null;
|
| 1071 |
+
}
|
| 1072 |
|
| 1073 |
if (audioSource) {
|
| 1074 |
try { audioSource.stop(); } catch (e) {}
|
| 1075 |
audioSource = null;
|
| 1076 |
}
|
| 1077 |
|
| 1078 |
+
// Voltar ao video idle (apenas muda a fonte do render loop)
|
| 1079 |
+
renderSource = 'idle';
|
|
|
|
| 1080 |
idleVideo.play().catch(e => {});
|
| 1081 |
+
console.log("stopStream: Transição para idle");
|
| 1082 |
|
| 1083 |
document.getElementById("generate-btn").disabled = false;
|
| 1084 |
document.getElementById("stop-btn").disabled = true;
|