marcosremar2 Claude Opus 4.5 commited on
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>

Files changed (1) hide show
  1. 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: 100%; height: 100%;
61
- object-fit: contain;
62
- z-index: 1;
63
- transition: opacity 0.2s ease;
64
  }
65
- #idle-video.hidden { opacity: 0; pointer-events: none; z-index: 0; }
66
  #avatar-canvas {
67
  position: absolute; top: 0; left: 0;
68
  width: 100%; height: 100%;
69
  object-fit: contain;
70
- opacity: 0;
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://" + window.location.host + "/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
- // Mostrar canvas, esconder idle
417
- console.log("Primeiro frame recebido!");
418
- canvas.classList.add("active");
419
- idleVideo.classList.add("hidden");
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
- function startRendering() {
447
- if (renderInterval) return;
448
- lastRenderTime = performance.now();
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
- // Agendar chunk para reprodução imediata
508
- scheduleAudioChunk(chunkData);
 
 
 
 
 
 
 
 
 
 
509
  }
510
 
511
  if (isLast) {
@@ -641,16 +753,22 @@
641
  // 1. Já iniciou? Sair
642
  if (playbackStarted) return;
643
 
644
- // 2. Temos frames suficientes? (pelo menos 10 frames = 400ms)
645
- if (allFrames.length < 10) {
646
- console.log(`Aguardando mais frames: ${allFrames.length}/10`);
 
 
 
 
 
 
 
647
  return;
648
  }
649
 
650
- // 3. Temos audio chunks suficientes para começar? (pelo menos 2 chunks)
651
- // O audio está sendo agendado conforme chega via scheduleAudioChunk
652
- if (audioScheduledChunks < 2) {
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
- console.log(`=== INICIANDO PLAYBACK ===`);
 
 
 
 
 
 
666
  console.log(`Latencia: ${playbackLatency}ms`);
667
- console.log(`Frames disponíveis: ${allFrames.length}`);
668
- console.log(`Audio chunks agendados: ${audioScheduledChunks}`);
 
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
- startRendering();
 
 
 
 
 
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 atual do vídeo idle (em ms)
735
- // O vídeo idle tem ~1-2 minutos e fica em loop
736
- const idleVideoTimeMs = Math.round(idleVideo.currentTime * 1000);
737
- console.log(`Idle video currentTime: ${idleVideo.currentTime}s (${idleVideoTimeMs}ms)`);
 
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
- // Parar render interval
 
 
 
 
 
 
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
- // Aguardar um momento antes de voltar ao idle (para áudio terminar)
807
- setTimeout(() => {
808
- // Voltar ao video idle - com transição suave se temos end_video_time_ms
809
- canvas.classList.remove("active");
810
- idleVideo.classList.remove("hidden");
811
-
812
- // Se servidor enviou end_video_time_ms, continuar o vídeo desse ponto
813
- if (endVideoTimeMs !== null) {
814
- const videoTimeSeconds = endVideoTimeMs / 1000;
815
- // Garantir que não excede a duração do vídeo (usar módulo para loop)
816
- const videoDuration = idleVideo.duration || 60;
817
- const seekTime = videoTimeSeconds % videoDuration;
818
- console.log(`Transição suave: vídeo idle em ${seekTime.toFixed(2)}s (end_video_time_ms=${endVideoTimeMs})`);
819
- idleVideo.currentTime = seekTime;
820
- }
821
 
822
- idleVideo.play().catch(e => {});
 
 
823
 
824
- // Reset estado
825
- isStreaming = false;
826
- endVideoTimeMs = null; // Resetar para próxima geração
827
- document.getElementById("generate-btn").disabled = false;
828
- document.getElementById("stop-btn").disabled = true;
829
- updateStatus("stream-status", "online", "Stream: Concluido");
830
- }, 500);
 
 
 
 
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
- canvas.classList.remove("active");
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 ( 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;