marcosremar2 Claude Sonnet 4.5 commited on
Commit
e62aafd
·
1 Parent(s): 64b0a86

Add WebRTC streaming interface with vast.ai deployment

Browse files

- Update WebRTC interface with race condition fix (isConnecting flag)
- Add automated test scripts (aiortc and Playwright)
- Add deployment scripts for vast.ai and SkyPilot
- Improve idle video sync and transitions
- Fix audio-video buffering for better sync

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

interface/deploy_to_server.sh ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ echo "==================================================="
3
+ echo "Deploy WebRTC para VPS"
4
+ echo "==================================================="
5
+
6
+ SERVER="root@62.107.25.198"
7
+ PORT="47824"
8
+ REMOTE_DIR="/workspace/interface"
9
+
10
+ echo ""
11
+ echo "Copiando arquivos para o servidor..."
12
+ echo ""
13
+
14
+ # Copiar arquivos principais
15
+ scp -P $PORT \
16
+ server.py \
17
+ index.html \
18
+ $SERVER:$REMOTE_DIR/
19
+
20
+ echo ""
21
+ echo "==================================================="
22
+ echo "Deploy concluído!"
23
+ echo "==================================================="
24
+ echo ""
25
+ echo "Para reiniciar o servidor:"
26
+ echo "ssh -p $PORT $SERVER"
27
+ echo "cd $REMOTE_DIR"
28
+ echo "pkill -f server.py"
29
+ echo "python3 server.py"
30
+ echo ""
interface/index.html CHANGED
@@ -3,458 +3,411 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Avatar Interface - WebM Streaming</title>
7
  <style>
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
  body {
10
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
11
  background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%);
12
  color: #fff;
13
  min-height: 100vh;
14
  padding: 20px;
15
  }
16
- .container { max-width: 1200px; margin: 0 auto; }
17
- h1 { text-align: center; margin-bottom: 20px; color: #00d4ff; }
18
 
19
- .status-bar {
20
- display: flex; gap: 20px; justify-content: center; margin-bottom: 20px;
21
- flex-wrap: wrap;
22
- }
23
- .status-item {
24
- padding: 10px 20px; border-radius: 20px;
25
- background: rgba(255,255,255,0.1);
26
  font-size: 14px;
 
27
  }
28
- .status-item.online { background: rgba(0,255,100,0.2); color: #0f0; }
29
- .status-item.offline { background: rgba(255,0,0,0.2); color: #f00; }
30
- .status-item.connecting { background: rgba(255,200,0,0.2); color: #fc0; }
31
-
32
- .main-content { display: flex; gap: 20px; flex-wrap: wrap; }
33
 
34
- .video-section {
35
- flex: 1; min-width: 400px;
36
- background: rgba(0,0,0,0.3); border-radius: 15px; padding: 20px;
37
- }
38
- .video-container {
39
- width: 100%; aspect-ratio: 16/9;
40
- background: #000; border-radius: 10px; overflow: hidden;
 
 
41
  position: relative;
42
  }
43
- #video-player {
44
- width: 100%; height: 100%;
 
45
  object-fit: contain;
46
- background: #000;
47
  }
48
- .video-overlay {
49
- position: absolute; top: 0; left: 0; right: 0; bottom: 0;
50
- display: flex; align-items: center; justify-content: center;
51
- background: rgba(0,0,0,0.7);
52
- font-size: 18px; color: #aaa;
53
- pointer-events: none;
54
  }
55
- .video-overlay.hidden { display: none; }
56
 
57
- .control-section {
58
- flex: 1; min-width: 300px;
59
- background: rgba(0,0,0,0.3); border-radius: 15px; padding: 20px;
 
 
60
  }
61
 
62
- .input-group { margin-bottom: 15px; }
63
- label { display: block; margin-bottom: 5px; color: #aaa; }
64
- textarea {
65
- width: 100%; height: 100px; padding: 10px;
66
- border: 1px solid #333; border-radius: 8px;
67
- background: rgba(255,255,255,0.05); color: #fff;
68
- resize: vertical; font-size: 14px;
69
  }
70
- select, button {
71
- width: 100%; padding: 12px; margin-top: 10px;
72
- border: none; border-radius: 8px; cursor: pointer;
73
- }
74
- select { background: rgba(255,255,255,0.1); color: #fff; }
75
 
76
- .btn-primary {
77
- background: linear-gradient(135deg, #00d4ff, #0066ff);
78
- color: #fff; font-weight: bold; font-size: 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
- .btn-danger {
81
- background: linear-gradient(135deg, #ff4444, #cc0000);
82
- color: #fff; font-weight: bold; font-size: 16px;
 
 
 
 
 
83
  }
84
  button:hover { opacity: 0.9; }
85
  button:disabled { opacity: 0.5; cursor: not-allowed; }
86
 
 
 
 
 
87
  .metrics {
88
- margin-top: 20px; padding: 15px;
89
- background: rgba(255,255,255,0.05); border-radius: 8px;
90
- }
91
- .metrics h4 { margin-bottom: 10px; color: #00d4ff; }
92
- .metric-row { display: flex; justify-content: space-between; padding: 5px 0; }
93
- .metric-value { color: #00d4ff; font-weight: bold; }
94
-
95
- .log {
96
- margin-top: 20px; padding: 10px;
97
- background: #000; border-radius: 8px;
98
- font-family: monospace; font-size: 12px;
99
- max-height: 150px; overflow-y: auto;
100
  }
101
- .log-entry { padding: 2px 0; border-bottom: 1px solid #222; }
102
- .log-time { color: #666; }
103
- .log-msg { color: #0f0; }
104
- .log-error { color: #f00; }
105
- .log-status { color: #fc0; }
106
  </style>
107
  </head>
108
  <body>
109
  <div class="container">
110
- <h1>Avatar Interface - WebM Streaming</h1>
111
 
112
- <div class="status-bar">
113
- <div class="status-item" id="ws-status">WebSocket: Desconectado</div>
114
- <div class="status-item" id="wav2lip-status">Wav2Lip: --</div>
115
- <div class="status-item" id="tts-status">TTS: --</div>
116
  </div>
117
 
118
- <div class="main-content">
119
- <div class="video-section">
120
- <h3>Video Stream</h3>
121
- <div class="video-container">
122
- <video id="video-player" autoplay></video>
123
- <div class="video-overlay" id="video-overlay">
124
- Aguardando video...
125
- </div>
126
- </div>
127
- </div>
128
-
129
- <div class="control-section">
130
- <h3>Controles</h3>
131
-
132
- <div class="input-group">
133
- <label>Texto para falar:</label>
134
- <textarea id="text-input" placeholder="Digite o texto aqui...">Hello! I am a real-time streaming avatar powered by AI.</textarea>
135
- </div>
136
-
137
- <div class="input-group">
138
- <label>Voz:</label>
139
- <select id="voice-select">
140
- <option value="tara">Tara (Female)</option>
141
- <option value="leah">Leah (Female)</option>
142
- <option value="jess">Jess (Female)</option>
143
- <option value="leo">Leo (Male)</option>
144
- <option value="dan">Dan (Male)</option>
145
- </select>
146
- </div>
147
-
148
- <button id="generate-btn" class="btn-primary" onclick="generate()">
149
- Gerar Avatar
150
- </button>
151
- <button id="stop-btn" class="btn-danger" onclick="stop()" disabled>
152
- Parar
153
- </button>
154
-
155
- <div class="metrics">
156
- <h4>Metricas</h4>
157
- <div class="metric-row">
158
- <span>Latencia:</span>
159
- <span class="metric-value" id="latency">--</span>
160
- </div>
161
- <div class="metric-row">
162
- <span>Frames:</span>
163
- <span class="metric-value" id="frames">0</span>
164
- </div>
165
- <div class="metric-row">
166
- <span>Chunks WebM:</span>
167
- <span class="metric-value" id="chunks">0</span>
168
- </div>
169
- <div class="metric-row">
170
- <span>Bytes recebidos:</span>
171
- <span class="metric-value" id="bytes">0 KB</span>
172
- </div>
173
- <div class="metric-row">
174
- <span>Duracao:</span>
175
- <span class="metric-value" id="duration">--</span>
176
- </div>
177
- </div>
178
-
179
- <div class="log" id="log"></div>
180
- </div>
181
  </div>
182
- </div>
183
-
184
- <script>
185
- // Configuracao
186
- const WS_URL = "ws://" + window.location.host + "/ws";
187
-
188
- // Estado
189
- let ws = null;
190
- let isGenerating = false;
191
- let startTime = null;
192
- let totalFrames = 0;
193
- let totalChunks = 0;
194
- let totalBytes = 0;
195
-
196
- // Elementos
197
- const video = document.getElementById("video-player");
198
- const overlay = document.getElementById("video-overlay");
199
-
200
- // Buffer de WebM para reproducao sequencial
201
- let webmQueue = [];
202
- let isPlaying = false;
203
-
204
- function log(msg, type = "msg") {
205
- const logDiv = document.getElementById("log");
206
- const time = new Date().toLocaleTimeString();
207
- logDiv.innerHTML = `<div class="log-entry"><span class="log-time">${time}</span> <span class="log-${type}">${msg}</span></div>` + logDiv.innerHTML;
208
-
209
- while (logDiv.children.length > 50) {
210
- logDiv.removeChild(logDiv.lastChild);
211
- }
212
- }
213
-
214
- function updateStatus(element, status, text) {
215
- const el = document.getElementById(element);
216
- el.textContent = text;
217
- el.className = "status-item " + status;
218
- }
219
-
220
- async function checkHealth() {
221
- try {
222
- const resp = await fetch("/health");
223
- const status = await resp.json();
224
-
225
- updateStatus("wav2lip-status",
226
- status.wav2lip ? "online" : "offline",
227
- "Wav2Lip: " + (status.wav2lip ? "Online" : "Offline")
228
- );
229
- updateStatus("tts-status",
230
- status.tts ? "online" : "offline",
231
- "TTS: " + (status.tts ? "Online" : "Offline")
232
- );
233
- } catch (e) {
234
- updateStatus("wav2lip-status", "offline", "Wav2Lip: Erro");
235
- updateStatus("tts-status", "offline", "TTS: Erro");
236
- }
237
- }
238
-
239
- function connectWebSocket() {
240
- if (ws && ws.readyState === WebSocket.OPEN) return;
241
 
242
- updateStatus("ws-status", "connecting", "WebSocket: Conectando...");
243
- log("Conectando ao WebSocket...", "status");
244
-
245
- ws = new WebSocket(WS_URL);
 
 
 
 
 
 
 
246
 
247
- ws.onopen = () => {
248
- updateStatus("ws-status", "online", "WebSocket: Conectado");
249
- log("WebSocket conectado", "msg");
250
- checkHealth();
251
- };
 
 
252
 
253
- ws.onmessage = (event) => {
254
- try {
255
- const data = JSON.parse(event.data);
256
- handleMessage(data);
257
- } catch (e) {
258
- log("Erro ao processar mensagem: " + e, "error");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  }
260
- };
261
-
262
- ws.onclose = () => {
263
- updateStatus("ws-status", "offline", "WebSocket: Desconectado");
264
- log("WebSocket desconectado", "error");
265
- setTimeout(connectWebSocket, 3000);
266
- };
267
-
268
- ws.onerror = (e) => {
269
- log("Erro no WebSocket", "error");
270
- };
271
- }
272
-
273
- function handleMessage(data) {
274
- const type = data.type;
275
-
276
- switch (type) {
277
- case "status":
278
- log(data.message, "status");
279
- break;
280
-
281
- case "webm_chunk":
282
- handleWebMChunk(data);
283
- break;
284
-
285
- case "done":
286
- handleDone(data);
287
- break;
288
-
289
- case "error":
290
- log("Erro: " + data.message, "error");
291
- stopGeneration();
292
- break;
293
-
294
- case "pong":
295
- break;
296
-
297
- default:
298
- console.log("Mensagem desconhecida:", data);
299
- }
300
- }
301
-
302
- function handleWebMChunk(data) {
303
- totalChunks++;
304
- document.getElementById("chunks").textContent = totalChunks;
305
-
306
- // Decodificar WebM
307
- const webmData = base64ToArrayBuffer(data.data);
308
- totalBytes += webmData.byteLength;
309
- document.getElementById("bytes").textContent = (totalBytes / 1024).toFixed(1) + " KB";
310
-
311
- // Atualizar latencia (primeiro chunk)
312
- if (startTime && totalChunks === 1) {
313
- const latency = Date.now() - startTime;
314
- document.getElementById("latency").textContent = latency + "ms";
315
- log(`Primeiro chunk em ${latency}ms`, "status");
316
  }
317
-
318
- // Esconder overlay
319
- overlay.classList.add("hidden");
320
-
321
- // Adicionar a fila e reproduzir
322
- webmQueue.push(webmData);
323
-
324
- if (!isPlaying) {
325
- playNextWebM();
326
  }
327
- }
328
-
329
- function playNextWebM() {
330
- if (webmQueue.length === 0) {
331
- isPlaying = false;
332
- return;
 
 
 
 
 
 
 
 
 
 
 
333
  }
334
-
335
- isPlaying = true;
336
- const webmData = webmQueue.shift();
337
-
338
- // Criar blob URL e reproduzir
339
- const blob = new Blob([webmData], { type: "video/webm" });
340
- const url = URL.createObjectURL(blob);
341
-
342
- // Quando o video terminar, reproduzir o proximo
343
- video.onended = () => {
344
- video.onended = null;
345
- URL.revokeObjectURL(url);
346
- playNextWebM();
347
- };
348
-
349
- video.src = url;
350
- video.play().catch(e => {
351
- log("Erro ao reproduzir: " + e, "error");
352
- playNextWebM();
353
- });
354
- }
355
-
356
- function handleDone(data) {
357
- totalFrames = data.total_frames;
358
- document.getElementById("frames").textContent = totalFrames;
359
- document.getElementById("duration").textContent = data.total_duration_ms + "ms";
360
-
361
- log(`Geracao concluida: ${data.total_frames} frames, ${data.total_duration_ms}ms`, "msg");
362
-
363
- isGenerating = false;
364
- document.getElementById("generate-btn").disabled = false;
365
- document.getElementById("stop-btn").disabled = true;
366
- }
367
-
368
- function base64ToArrayBuffer(base64) {
369
- const binaryString = atob(base64);
370
- const bytes = new Uint8Array(binaryString.length);
371
- for (let i = 0; i < binaryString.length; i++) {
372
- bytes[i] = binaryString.charCodeAt(i);
373
  }
374
- return bytes.buffer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  }
376
 
377
- function generate() {
378
- const text = document.getElementById("text-input").value.trim();
379
- const voice = document.getElementById("voice-select").value;
380
-
381
- if (!text) {
382
- log("Digite um texto", "error");
383
- return;
384
- }
385
-
386
- if (!ws || ws.readyState !== WebSocket.OPEN) {
387
- log("WebSocket nao conectado", "error");
388
- return;
389
- }
390
-
391
- // Reset estado
392
- webmQueue = [];
393
- totalFrames = 0;
394
- totalChunks = 0;
395
- totalBytes = 0;
396
- isPlaying = false;
397
- startTime = Date.now();
398
-
399
- // Reset video
400
- video.onended = null;
401
- if (video.src) URL.revokeObjectURL(video.src);
402
- video.removeAttribute("src");
403
-
404
- // Reset UI
405
- document.getElementById("frames").textContent = "0";
406
- document.getElementById("chunks").textContent = "0";
407
- document.getElementById("bytes").textContent = "0 KB";
408
- document.getElementById("latency").textContent = "--";
409
- document.getElementById("duration").textContent = "--";
410
- overlay.textContent = "Gerando...";
411
- overlay.classList.remove("hidden");
412
-
413
- // Atualizar botoes
414
- isGenerating = true;
415
- document.getElementById("generate-btn").disabled = true;
416
- document.getElementById("stop-btn").disabled = false;
417
-
418
- log("Enviando: " + text.substring(0, 50) + "...", "status");
419
-
420
- // Enviar requisicao
421
- ws.send(JSON.stringify({
422
- action: "generate",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  text: text,
424
- voice: voice
425
- }));
426
- }
427
 
428
- function stop() {
429
- if (ws && ws.readyState === WebSocket.OPEN) {
430
- ws.send(JSON.stringify({ action: "stop" }));
431
- }
432
- stopGeneration();
433
  }
434
 
435
- function stopGeneration() {
436
- isGenerating = false;
437
- isPlaying = false;
438
- webmQueue = [];
439
- video.pause();
440
- video.onended = null;
441
- if (video.src) URL.revokeObjectURL(video.src);
442
- document.getElementById("generate-btn").disabled = false;
443
- document.getElementById("stop-btn").disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  }
445
-
446
- // Heartbeat
447
- setInterval(() => {
448
- if (ws && ws.readyState === WebSocket.OPEN) {
449
- ws.send(JSON.stringify({ action: "ping" }));
450
- }
451
- }, 30000);
452
-
453
- // Health check
454
- setInterval(checkHealth, 10000);
455
-
456
- // Inicializar
457
- connectWebSocket();
458
- </script>
 
 
 
459
  </body>
460
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Avatar - WebRTC</title>
7
  <style>
8
  * { margin: 0; padding: 0; box-sizing: border-box; }
9
  body {
10
+ font-family: system-ui, sans-serif;
11
  background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3a 100%);
12
  color: #fff;
13
  min-height: 100vh;
14
  padding: 20px;
15
  }
16
+ .container { max-width: 900px; margin: 0 auto; }
 
17
 
18
+ .status {
19
+ text-align: center;
20
+ padding: 10px;
21
+ margin-bottom: 15px;
22
+ border-radius: 8px;
 
 
23
  font-size: 14px;
24
+ background: rgba(255,255,255,0.1);
25
  }
26
+ .status.connected { background: rgba(0,255,100,0.2); color: #0f0; }
27
+ .status.busy { background: rgba(255,200,0,0.2); color: #fc0; }
28
+ .status.error { background: rgba(255,0,0,0.2); color: #f55; }
 
 
29
 
30
+ .video-box {
31
+ background: #000;
32
+ border-radius: 10px;
33
+ overflow: hidden;
34
+ margin-bottom: 20px;
35
+ aspect-ratio: 16/9;
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
  position: relative;
40
  }
41
+ video {
42
+ max-width: 100%;
43
+ max-height: 100%;
44
  object-fit: contain;
 
45
  }
46
+ .placeholder {
47
+ color: #666;
48
+ font-size: 14px;
49
+ position: absolute;
 
 
50
  }
 
51
 
52
+ .controls {
53
+ display: flex;
54
+ gap: 10px;
55
+ margin-bottom: 15px;
56
+ flex-wrap: wrap;
57
  }
58
 
59
+ .call-controls {
60
+ display: flex;
61
+ gap: 10px;
62
+ margin-bottom: 20px;
 
 
 
63
  }
 
 
 
 
 
64
 
65
+ textarea {
66
+ flex: 1;
67
+ min-width: 200px;
68
+ padding: 12px;
69
+ border: 1px solid #333;
70
+ border-radius: 8px;
71
+ background: #1a1a2e;
72
+ color: #fff;
73
+ font-size: 14px;
74
+ resize: none;
75
+ height: 60px;
76
+ }
77
+ select {
78
+ padding: 12px;
79
+ border: 1px solid #333;
80
+ border-radius: 8px;
81
+ background: #1a1a2e;
82
+ color: #fff;
83
+ font-size: 14px;
84
  }
85
+ button {
86
+ padding: 12px 24px;
87
+ border: none;
88
+ border-radius: 8px;
89
+ font-size: 14px;
90
+ font-weight: bold;
91
+ cursor: pointer;
92
+ transition: opacity 0.2s;
93
  }
94
  button:hover { opacity: 0.9; }
95
  button:disabled { opacity: 0.5; cursor: not-allowed; }
96
 
97
+ .btn-call { background: #00aaff; color: #fff; }
98
+ .btn-call.active { background: #ff4444; }
99
+ .btn-generate { background: #00ff88; color: #000; flex: 0 0 auto; }
100
+
101
  .metrics {
102
+ display: grid;
103
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
104
+ gap: 8px;
105
+ padding: 12px;
106
+ background: #1a1a2e;
107
+ border-radius: 8px;
108
+ font-size: 12px;
 
 
 
 
 
109
  }
110
+ .metric { display: flex; justify-content: space-between; }
111
+ .val { color: #00ff88; font-family: monospace; }
 
 
 
112
  </style>
113
  </head>
114
  <body>
115
  <div class="container">
116
+ <div class="status" id="status">Desconectado</div>
117
 
118
+ <div class="video-box">
119
+ <video id="video" autoplay playsinline></video>
120
+ <div class="placeholder" id="placeholder">Clique em "Conectar" para iniciar</div>
 
121
  </div>
122
 
123
+ <div class="call-controls">
124
+ <button class="btn-call" id="btnConnect">Conectar</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
+ <div class="controls">
128
+ <textarea id="text" placeholder="Digite o texto para o avatar falar...">Hello! I am testing the WebRTC streaming avatar with VP9 codec.</textarea>
129
+ <select id="voice">
130
+ <option value="tara">Tara</option>
131
+ <option value="leah">Leah</option>
132
+ <option value="jess">Jess</option>
133
+ <option value="leo">Leo</option>
134
+ <option value="dan">Dan</option>
135
+ </select>
136
+ <button class="btn-generate" id="btnGenerate" disabled>Gerar</button>
137
+ </div>
138
 
139
+ <div class="metrics">
140
+ <div class="metric"><span>WebRTC:</span><span class="val" id="mWebrtc">--</span></div>
141
+ <div class="metric"><span>Video:</span><span class="val" id="mVideo">--</span></div>
142
+ <div class="metric"><span>Audio:</span><span class="val" id="mAudio">--</span></div>
143
+ <div class="metric"><span>Latencia:</span><span class="val" id="mLatency">--</span></div>
144
+ </div>
145
+ </div>
146
 
147
+ <script>
148
+ const video = document.getElementById('video');
149
+ const status = document.getElementById('status');
150
+ const placeholder = document.getElementById('placeholder');
151
+ const btnConnect = document.getElementById('btnConnect');
152
+ const btnGenerate = document.getElementById('btnGenerate');
153
+
154
+ // Global variables (para debug)
155
+ window.pc = null;
156
+ let pc = null;
157
+ let sessionId = null;
158
+ let isConnected = false;
159
+ let isConnecting = false; // Flag para prevenir múltiplas chamadas simultâneas
160
+
161
+ function setStatus(txt, cls) {
162
+ status.textContent = txt;
163
+ status.className = 'status ' + (cls || '');
164
+ }
165
+
166
+ function setMetric(id, val) {
167
+ document.getElementById(id).textContent = val;
168
+ }
169
+
170
+ async function connect() {
171
+ if (isConnected) {
172
+ disconnect();
173
+ return;
174
+ }
175
+
176
+ // Prevenir múltiplas chamadas simultâneas
177
+ if (isConnecting) {
178
+ console.log('Conexão já em andamento, ignorando...');
179
+ return;
180
+ }
181
+
182
+ isConnecting = true;
183
+ setStatus('Conectando...', 'busy');
184
+
185
+ try {
186
+ // Criar RTCPeerConnection com STUN + TURN múltiplos
187
+ pc = window.pc = new RTCPeerConnection({
188
+ iceServers: [
189
+ { urls: 'stun:stun.l.google.com:19302' },
190
+ // Servidores TURN públicos - múltiplas opções
191
+ {
192
+ urls: [
193
+ 'turn:openrelay.metered.ca:80',
194
+ 'turn:openrelay.metered.ca:443',
195
+ 'turn:openrelay.metered.ca:443?transport=tcp'
196
+ ],
197
+ username: 'openrelayproject',
198
+ credential: 'openrelayproject'
199
+ },
200
+ // Servidor TURN alternativo (Twilio)
201
+ {
202
+ urls: 'turn:global.turn.twilio.com:3478?transport=udp',
203
+ username: 'f4b4035eaa76f4a55de5f4351567653ee4ff6fa97b50b6b334fcc1be9c27212d',
204
+ credential: 'w1uxM55V9yVoqyVFjt+mxDBV0F87AUCemaYVQGxsPLw='
205
  }
206
+ ],
207
+ iceCandidatePoolSize: 10,
208
+ bundlePolicy: 'max-bundle',
209
+ rtcpMuxPolicy: 'require'
210
+ });
211
+
212
+ // Handler para tracks recebidos
213
+ pc.ontrack = (event) => {
214
+ console.log('Track recebido:', event.track.kind);
215
+ if (event.track.kind === 'video') {
216
+ video.srcObject = event.streams[0];
217
+ placeholder.style.display = 'none';
218
+ setMetric('mVideo', 'Ativo');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  }
220
+ if (event.track.kind === 'audio') {
221
+ setMetric('mAudio', 'Ativo');
 
 
 
 
 
 
 
222
  }
223
+ };
224
+
225
+ // Handler para mudancas de estado
226
+ pc.onconnectionstatechange = () => {
227
+ console.log('Estado WebRTC:', pc.connectionState);
228
+ setMetric('mWebrtc', pc.connectionState);
229
+
230
+ if (pc.connectionState === 'connected') {
231
+ isConnected = true;
232
+ isConnecting = false; // Reset flag quando conectado
233
+ updateConnectButton();
234
+ setStatus('Conectado - Streaming ativo', 'connected');
235
+ btnGenerate.disabled = false;
236
+ startStatsMonitor();
237
+ } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
238
+ isConnecting = false; // Reset flag em caso de falha
239
+ disconnect();
240
  }
241
+ };
242
+
243
+ pc.oniceconnectionstatechange = () => {
244
+ console.log('ICE State:', pc.iceConnectionState);
245
+ };
246
+
247
+ // Debug: Log ICE candidates
248
+ pc.onicecandidate = (event) => {
249
+ if (event.candidate) {
250
+ console.log('ICE Candidate:', {
251
+ type: event.candidate.type,
252
+ protocol: event.candidate.protocol,
253
+ address: event.candidate.address,
254
+ port: event.candidate.port
255
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  }
257
+ };
258
+
259
+ // Criar transceiver para receber video e audio
260
+ pc.addTransceiver('video', { direction: 'recvonly' });
261
+ pc.addTransceiver('audio', { direction: 'recvonly' });
262
+
263
+ // Criar offer
264
+ const offer = await pc.createOffer();
265
+ await pc.setLocalDescription(offer);
266
+
267
+ // Aguardar alguns candidatos ICE (mas não esperar complete)
268
+ // Trickle ICE: enviamos o offer logo e candidatos vão depois
269
+ await new Promise(resolve => setTimeout(resolve, 500));
270
+
271
+ console.log('Enviando offer para servidor...');
272
+
273
+ // Enviar offer para o servidor
274
+ const response = await fetch('/offer', {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({
278
+ sdp: pc.localDescription.sdp,
279
+ type: pc.localDescription.type
280
+ })
281
+ });
282
+
283
+ if (!response.ok) {
284
+ throw new Error('Erro ao conectar: ' + response.status);
285
  }
286
 
287
+ const answer = await response.json();
288
+ sessionId = answer.session_id;
289
+
290
+ // Aplicar answer
291
+ await pc.setRemoteDescription(new RTCSessionDescription({
292
+ sdp: answer.sdp,
293
+ type: answer.type
294
+ }));
295
+
296
+ console.log('Session ID:', sessionId);
297
+
298
+ } catch (err) {
299
+ console.error('Erro ao conectar:', err);
300
+ isConnecting = false; // Reset flag em caso de erro
301
+ setStatus('Erro: ' + err.message, 'error');
302
+ disconnect();
303
+ }
304
+ }
305
+
306
+ function disconnect() {
307
+ if (pc) {
308
+ pc.close();
309
+ pc = null;
310
+ }
311
+
312
+ isConnected = false;
313
+ sessionId = null;
314
+ video.srcObject = null;
315
+ placeholder.style.display = 'block';
316
+
317
+ updateConnectButton();
318
+ btnGenerate.disabled = true;
319
+
320
+ setStatus('Desconectado');
321
+ setMetric('mWebrtc', '--');
322
+ setMetric('mVideo', '--');
323
+ setMetric('mAudio', '--');
324
+ setMetric('mLatency', '--');
325
+ }
326
+
327
+ function updateConnectButton() {
328
+ if (isConnected) {
329
+ btnConnect.textContent = 'Desconectar';
330
+ btnConnect.classList.add('active');
331
+ } else {
332
+ btnConnect.textContent = 'Conectar';
333
+ btnConnect.classList.remove('active');
334
+ }
335
+ }
336
+
337
+ async function generate() {
338
+ const text = document.getElementById('text').value.trim();
339
+ if (!text || !sessionId) return;
340
+
341
+ setStatus('Gerando fala...', 'busy');
342
+ btnGenerate.disabled = true;
343
+
344
+ try {
345
+ const response = await fetch('/generate', {
346
+ method: 'POST',
347
+ headers: { 'Content-Type': 'application/json' },
348
+ body: JSON.stringify({
349
+ session_id: sessionId,
350
  text: text,
351
+ voice: document.getElementById('voice').value
352
+ })
353
+ });
354
 
355
+ if (!response.ok) {
356
+ const err = await response.json();
357
+ throw new Error(err.error || 'Erro ao gerar');
 
 
358
  }
359
 
360
+ setStatus('Reproduzindo...', 'connected');
361
+
362
+ } catch (err) {
363
+ console.error('Erro ao gerar:', err);
364
+ setStatus('Erro: ' + err.message, 'error');
365
+ } finally {
366
+ btnGenerate.disabled = false;
367
+ }
368
+ }
369
+
370
+ function startStatsMonitor() {
371
+ setInterval(async () => {
372
+ if (!pc || !isConnected) return;
373
+
374
+ try {
375
+ const stats = await pc.getStats();
376
+ stats.forEach(report => {
377
+ if (report.type === 'inbound-rtp' && report.kind === 'video') {
378
+ const fps = report.framesPerSecond || 0;
379
+ const width = report.frameWidth || 0;
380
+ const height = report.frameHeight || 0;
381
+ if (width && height) {
382
+ setMetric('mVideo', `${width}x${height} @${fps.toFixed(0)}fps`);
383
+ }
384
+ }
385
+ if (report.type === 'candidate-pair' && report.state === 'succeeded') {
386
+ const rtt = report.currentRoundTripTime;
387
+ if (rtt) {
388
+ setMetric('mLatency', `${(rtt * 1000).toFixed(0)}ms`);
389
+ }
390
+ }
391
+ });
392
+ } catch (e) {
393
+ // Ignorar erros de stats
394
  }
395
+ }, 1000);
396
+ }
397
+
398
+ // Event listeners
399
+ btnConnect.onclick = connect;
400
+ btnGenerate.onclick = generate;
401
+
402
+ // Atalho Enter no textarea
403
+ document.getElementById('text').onkeydown = (e) => {
404
+ if (e.key === 'Enter' && !e.shiftKey) {
405
+ e.preventDefault();
406
+ if (!btnGenerate.disabled) {
407
+ generate();
408
+ }
409
+ }
410
+ };
411
+ </script>
412
  </body>
413
  </html>
interface/index_optimized.html CHANGED
@@ -138,7 +138,7 @@
138
  <div class="video-section">
139
  <h3 style="margin-bottom:10px">Avatar Stream (Binary Mode)</h3>
140
  <div class="canvas-container">
141
- <video id="idle-video" src="/idle.mp4" loop muted playsinline autoplay></video>
142
  <canvas id="avatar-canvas"></canvas>
143
  </div>
144
  </div>
@@ -162,13 +162,47 @@
162
  </select>
163
  </div>
164
 
165
- <button id="generate-btn" class="btn-primary" onclick="generate()">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  Gerar
167
  </button>
168
  <button id="stop-btn" class="btn-danger" onclick="stop()" disabled>
169
  Parar
170
  </button>
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  <div class="metrics">
173
  <h4>Metricas (Binary)</h4>
174
  <div class="metric-row">
@@ -243,17 +277,32 @@
243
  let syncedFrameInterval = FRAME_INTERVAL;
244
  let playbackStarted = false;
245
 
246
- // Streaming de audio real
 
 
 
 
 
 
247
  let nextAudioTime = 0; // Próximo tempo para agendar audio
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
@@ -280,63 +329,85 @@
280
  let speakingFrameIndex = 0;
281
  let lastSpeakingRenderTime = 0;
282
 
283
- // Crossfade transition variables
284
- let isTransitioning = false;
285
- let transitionStartTime = 0;
286
- let lastSpeakingFrame = null; // Guarda o último frame do speaking para crossfade
287
- const TRANSITION_DURATION_MS = 300; // Duração do crossfade em ms
288
 
289
  function startUnifiedRenderLoop() {
290
  if (unifiedRenderLoop) return; // Já está rodando
291
 
292
  function renderFrame() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
  if (renderSource === 'idle') {
294
- // Se estamos em transição, fazer crossfade
295
- if (isTransitioning && lastSpeakingFrame) {
296
- const now = performance.now();
297
- const elapsed = now - transitionStartTime;
298
- const progress = Math.min(elapsed / TRANSITION_DURATION_MS, 1);
299
-
300
- // Desenha o último frame do speaking
301
- ctx.globalAlpha = 1 - progress;
302
- ctx.drawImage(lastSpeakingFrame, 0, 0, canvas.width, canvas.height);
303
-
304
- // Desenha o vídeo idle por cima com alpha crescente
305
- ctx.globalAlpha = progress;
306
- if (idleVideo.readyState >= 2) {
307
- ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
308
- }
309
-
310
- // Restaurar alpha
311
- ctx.globalAlpha = 1;
312
-
313
- // Fim da transição
314
- if (progress >= 1) {
315
- isTransitioning = false;
316
- lastSpeakingFrame = null;
317
- console.log("Crossfade concluído");
318
- }
319
  } else {
320
- // Desenha o frame atual do vídeo idle no canvas
321
- if (idleVideo.readyState >= 2) { // HAVE_CURRENT_DATA
322
- ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
323
- }
324
  }
325
  } else if (renderSource === 'speaking') {
326
  // Desenha frames do servidor com timing controlado
327
  const now = performance.now();
328
  const elapsed = now - lastSpeakingRenderTime;
329
 
330
- // SINCRONIZAÇÃO: Verificar se o áudio já terminou
331
- // Se o tempo esperado de fim do áudio já passou, parar de renderizar frames
332
- if (audioExpectedEndTime > 0 && now >= audioExpectedEndTime) {
333
- console.log(`Audio terminou (${now.toFixed(0)} >= ${audioExpectedEndTime.toFixed(0)}), finalizando video`);
334
- // Salvar último frame para crossfade
335
- saveLastFrameForTransition();
 
 
 
 
 
 
336
  finishPlayback();
337
- return;
 
 
 
 
 
 
 
338
  }
339
 
 
340
  if (elapsed >= syncedFrameInterval && frameQueue.length > 0) {
341
  const frameData = frameQueue.shift();
342
 
@@ -367,12 +438,6 @@
367
  const bw = totalElapsed > 0 ? (totalBytes / 1024 / totalElapsed) : 0;
368
  updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`);
369
 
370
- // Verificar se terminou
371
- if (streamDone && frameQueue.length === 0) {
372
- // Salvar último frame para crossfade
373
- saveLastFrameForTransition();
374
- finishPlayback();
375
- }
376
  }
377
  }
378
 
@@ -391,31 +456,68 @@
391
  }
392
  }
393
 
394
- // Salvar o último frame do speaking para crossfade
395
- function saveLastFrameForTransition() {
396
- // Criar uma cópia do canvas atual como imagem
397
- const tempCanvas = document.createElement('canvas');
398
- tempCanvas.width = canvas.width;
399
- tempCanvas.height = canvas.height;
400
- const tempCtx = tempCanvas.getContext('2d');
401
- tempCtx.drawImage(canvas, 0, 0);
402
-
403
- // Criar imagem a partir do canvas
404
- const img = new Image();
405
- img.src = tempCanvas.toDataURL('image/jpeg', 0.95);
406
- lastSpeakingFrame = img;
407
-
408
- console.log("Último frame salvo para crossfade");
409
- }
410
-
411
  // Iniciar o render loop quando a página carrega
412
  idleVideo.addEventListener('loadeddata', () => {
413
  // Ajustar tamanho do canvas para match do vídeo
414
  canvas.width = idleVideo.videoWidth || 512;
415
  canvas.height = idleVideo.videoHeight || 512;
416
- startUnifiedRenderLoop();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  });
418
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
  function log(msg, type = "msg") {
420
  const logDiv = document.getElementById("log");
421
  const time = new Date().toLocaleTimeString();
@@ -559,16 +661,26 @@
559
  break;
560
 
561
  case "first_frame":
562
- // Apenas log - latência real é calculada no início do playback
 
 
 
 
563
  log(`Primeiro frame (server): ${data.latency_ms}ms`, "status");
564
  break;
565
 
566
  case "done":
567
- // Salvar o tempo final do vídeo para transição suave
 
 
 
 
 
 
568
  if (data.end_video_time_ms !== undefined) {
569
  endVideoTimeMs = data.end_video_time_ms;
570
- console.log(`Transição: vídeo idle continuará em ${endVideoTimeMs}ms`);
571
  }
 
572
 
573
  const framesCount = data.total_frames || data.frames || allFrames.length;
574
  const bytesInfo = data.bytes_sent ? `${(data.bytes_sent/1024).toFixed(1)}KB` : '';
@@ -598,8 +710,8 @@
598
  // Acumular frames
599
  allFrames.push({ url, index: frameIndex });
600
 
601
- // Se já está reproduzindo, adicionar ao buffer também
602
- if (playbackStarted) {
603
  frameQueue.push({ url, index: frameIndex });
604
  document.getElementById("buffer").textContent = frameQueue.length;
605
  }
@@ -612,13 +724,11 @@
612
  document.getElementById("video-duration").textContent = videoDuration.toFixed(2) + "s";
613
  updateSyncDiff();
614
 
615
- if (!playbackStarted) {
616
  document.getElementById("buffer").textContent = received + " (buffering)";
617
  }
618
 
619
  if (received === 1) {
620
- // Primeiro frame - apenas log, NÃO mudar para speaking ainda
621
- // (vai mudar quando playback sincronizado começar)
622
  console.log("Primeiro frame recebido - buffering...");
623
  updateStatus("stream-status", "streaming", "Stream: Buffering...");
624
  }
@@ -656,8 +766,8 @@
656
  initAudioContext();
657
  }
658
 
659
- // Acumular chunk (NÃO tocar ainda - esperar sync)
660
  if (chunkData.length > 0) {
 
661
  audioChunks.push(chunkData);
662
 
663
  // Acumular samples para calcular duração
@@ -671,7 +781,12 @@
671
  document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
672
  updateSyncDiff();
673
 
674
- console.log(`Audio chunk ${chunkIndex}: ${chunkData.length} bytes, total=${audioChunks.length}, duration=${totalAudioDuration.toFixed(2)}s`);
 
 
 
 
 
675
  }
676
 
677
  if (isLast) {
@@ -680,10 +795,57 @@
680
  console.log(`Todos chunks recebidos: ${audioChunks.length}`);
681
  }
682
 
683
- // Tentar iniciar frames (vídeo) quando tiver audio suficiente
684
  tryStartStreamingPlayback();
685
  }
686
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
  function initAudioContext() {
688
  if (!audioContext) {
689
  audioContext = new (window.AudioContext || window.webkitAudioContext)({
@@ -803,60 +965,128 @@
803
  }
804
 
805
  function tryStartStreamingPlayback() {
806
- // Condições para iniciar renderização de frames:
807
- // 1. Já iniciou? Sair
808
  if (playbackStarted) return;
809
 
810
- // 2. Esperar stream completo (todos frames e áudio)
811
- // Isso permite sincronização perfeita entre áudio e vídeo
812
- if (!streamDone) {
813
- console.log(`Aguardando stream completo...`);
814
- return;
815
- }
816
 
817
- // 3. Temos frames?
818
- if (allFrames.length === 0) {
819
- console.log(`Nenhum frame recebido`);
820
- return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
  }
822
 
823
- // 4. Temos áudio?
824
- if (totalAudioSamples === 0) {
825
- console.log(`Nenhum áudio recebido`);
 
 
 
 
 
 
 
 
 
826
  return;
827
  }
828
 
829
- // Pronto para iniciar renderização de frames!
830
- playbackStarted = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
 
832
- // Calcular latência
 
 
 
 
 
833
  const playbackLatency = Date.now() - startTime;
834
  document.getElementById("latency").textContent = playbackLatency + "ms";
835
  log(`Latencia: ${playbackLatency}ms`, "status");
836
 
837
- // *** SINCRONIZAÇÃO: Calcular FPS baseado na duração real do áudio ***
838
- const audioDurationSec = totalAudioSamples / 24000;
839
- const totalFrames = allFrames.length;
840
- const calculatedFps = totalFrames / audioDurationSec;
841
- syncedFrameInterval = 1000 / calculatedFps;
842
 
843
- console.log(`=== INICIANDO PLAYBACK SINCRONIZADO ===`);
844
- console.log(`Latencia: ${playbackLatency}ms`);
845
- console.log(`Frames: ${totalFrames}`);
846
- console.log(`Audio: ${audioDurationSec.toFixed(2)}s (${totalAudioSamples} samples)`);
847
- console.log(`FPS calculado: ${calculatedFps.toFixed(2)} (interval: ${syncedFrameInterval.toFixed(1)}ms)`);
848
 
849
- // Iniciar renderização de frames via render loop unificado
850
  frameQueue = [...allFrames];
851
  document.getElementById("buffer").textContent = frameQueue.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852
  renderSource = 'speaking';
853
  lastSpeakingRenderTime = performance.now();
854
 
855
- // Reiniciar o áudio do início para sincronizar com os frames
856
- // (os chunks já foram agendados, precisamos recomeçar)
857
- restartAudioPlayback();
858
 
859
- updateStatus("stream-status", "streaming", "Stream: Reproduzindo");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
860
  }
861
 
862
  function restartAudioPlayback() {
@@ -1006,10 +1236,14 @@
1006
  currentAudioSource = null;
1007
  audioPlaybackStartTime = 0;
1008
  audioExpectedEndTime = 0;
 
 
1009
 
1010
- // Reset crossfade state
1011
- isTransitioning = false;
1012
- lastSpeakingFrame = null;
 
 
1013
 
1014
  document.getElementById("frames").textContent = "0";
1015
  document.getElementById("rendered").textContent = "0";
@@ -1026,6 +1260,15 @@
1026
  document.getElementById("stop-btn").disabled = false;
1027
  updateStatus("stream-status", "", "Stream: Iniciando...");
1028
 
 
 
 
 
 
 
 
 
 
1029
  log("Enviando: " + text.substring(0, 30) + "...", "status");
1030
 
1031
  // Enviar com o timestamp do vídeo idle para sincronização
@@ -1033,7 +1276,8 @@
1033
  action: "generate",
1034
  text: text,
1035
  voice: voice,
1036
- idle_video_time_ms: idleVideoTimeMs // Timestamp exato do vídeo idle
 
1037
  }));
1038
  }
1039
 
@@ -1062,13 +1306,27 @@
1062
  log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
1063
  console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
1064
 
1065
- // Transição IMEDIATA para idle (não precisa esperar)
1066
- // Se servidor enviou end_video_time_ms, continuar o vídeo desse ponto
1067
- if (endVideoTimeMs !== null) {
1068
- const videoTimeSeconds = endVideoTimeMs / 1000;
1069
  const videoDuration = idleVideo.duration || 60;
1070
- const seekTime = videoTimeSeconds % videoDuration;
1071
- console.log(`Transição suave: vídeo idle em ${seekTime.toFixed(2)}s (end_video_time_ms=${endVideoTimeMs})`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
  idleVideo.currentTime = seekTime;
1073
  }
1074
 
@@ -1076,16 +1334,9 @@
1076
  idleVideo.loop = true;
1077
  idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
1078
 
1079
- // Iniciar crossfade se temos o último frame salvo
1080
- if (lastSpeakingFrame) {
1081
- isTransitioning = true;
1082
- transitionStartTime = performance.now();
1083
- console.log("Iniciando crossfade de 300ms");
1084
- }
1085
-
1086
- // Transição: speaking → idle (só muda a fonte, canvas continua renderizando)
1087
  renderSource = 'idle';
1088
- console.log("Transição para idle - renderSource mudou, idle video playing");
1089
 
1090
  // Reset estado de audio
1091
  audioExpectedEndTime = 0;
@@ -1093,8 +1344,14 @@
1093
 
1094
  // Reset estado geral
1095
  isStreaming = false;
1096
- endVideoTimeMs = null;
1097
  playbackStarted = false;
 
 
 
 
 
 
 
1098
  document.getElementById("generate-btn").disabled = false;
1099
  document.getElementById("stop-btn").disabled = true;
1100
  updateStatus("stream-status", "online", "Stream: Concluido");
@@ -1123,12 +1380,14 @@
1123
  audioDuration = 0;
1124
  audioBuffer = null;
1125
  playbackStarted = false;
 
1126
  nextAudioTime = 0;
1127
  audioScheduledChunks = 0;
1128
  firstChunkTime = null;
1129
  totalAudioSamples = 0;
1130
  audioPlaybackStartTime = 0;
1131
  audioExpectedEndTime = 0;
 
1132
 
1133
  if (currentAudioSource) {
1134
  try { currentAudioSource.stop(); } catch (e) {}
@@ -1140,6 +1399,12 @@
1140
  audioSource = null;
1141
  }
1142
 
 
 
 
 
 
 
1143
  // Voltar ao video idle (apenas muda a fonte do render loop)
1144
  renderSource = 'idle';
1145
  idleVideo.play().catch(e => {});
@@ -1157,6 +1422,139 @@
1157
  }
1158
  }, 30000);
1159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1160
  connectWebSocket();
1161
  </script>
1162
  </body>
 
138
  <div class="video-section">
139
  <h3 style="margin-bottom:10px">Avatar Stream (Binary Mode)</h3>
140
  <div class="canvas-container">
141
+ <video id="idle-video" loop muted playsinline autoplay preload="auto" src="idle.mp4"></video>
142
  <canvas id="avatar-canvas"></canvas>
143
  </div>
144
  </div>
 
162
  </select>
163
  </div>
164
 
165
+ <div class="input-group" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid rgba(255,255,255,0.1);">
166
+ <label>Qualidade Video Fala: <span id="quality-value">95</span>%</label>
167
+ <input type="range" id="quality-slider" min="50" max="100" value="95"
168
+ style="width: 100%; margin-top: 5px;"
169
+ oninput="document.getElementById('quality-value').textContent = this.value">
170
+ <small style="color: #666; font-size: 11px;">Aumentar para igualar com vídeo idle</small>
171
+ </div>
172
+
173
+ <div class="input-group">
174
+ <label>Offset Transição: <span id="offset-value">0</span>ms</label>
175
+ <input type="range" id="offset-slider" min="-500" max="500" value="0"
176
+ style="width: 100%; margin-top: 5px;"
177
+ oninput="document.getElementById('offset-value').textContent = this.value">
178
+ <small style="color: #666; font-size: 11px;">Ajuste fino do momento de voltar ao idle</small>
179
+ </div>
180
+
181
+ <button id="generate-btn" class="btn-primary" onclick="generate()" style="margin-top: 15px;">
182
  Gerar
183
  </button>
184
  <button id="stop-btn" class="btn-danger" onclick="stop()" disabled>
185
  Parar
186
  </button>
187
 
188
+ <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);">
189
+ <label style="color: #fc0; font-weight: bold;">Modo Demo</label>
190
+ <div style="display: flex; gap: 10px; margin-top: 8px; align-items: center;">
191
+ <button id="demo-btn" class="btn-primary" onclick="toggleDemo()" style="flex: 1; background: linear-gradient(135deg, #fc0, #f90); margin: 0;">
192
+ Iniciar Demo
193
+ </button>
194
+ </div>
195
+ <div style="display: flex; gap: 10px; margin-top: 10px;">
196
+ <div style="flex: 1;">
197
+ <label style="font-size: 11px;">Idle: <span id="demo-idle-value">5</span>s</label>
198
+ <input type="range" id="demo-idle-slider" min="1" max="30" value="5"
199
+ style="width: 100%;"
200
+ oninput="document.getElementById('demo-idle-value').textContent = this.value">
201
+ </div>
202
+ </div>
203
+ <small style="color: #666; font-size: 11px;">Alterna entre falar e idle automaticamente</small>
204
+ </div>
205
+
206
  <div class="metrics">
207
  <h4>Metricas (Binary)</h4>
208
  <div class="metric-row">
 
277
  let syncedFrameInterval = FRAME_INTERVAL;
278
  let playbackStarted = false;
279
 
280
+ // === REPRODUÇÃO PROGRESSIVA ===
281
+ // Configurações de buffer mínimo para começar a reproduzir
282
+ const MIN_FRAMES_TO_START = 5; // Mínimo de frames antes de iniciar (200ms de vídeo)
283
+ const MIN_AUDIO_CHUNKS_TO_START = 2; // Mínimo de chunks de áudio antes de iniciar
284
+ const PROGRESSIVE_MODE = true; // Ativar reprodução progressiva
285
+
286
+ // Streaming de audio real - agendamento sequencial
287
  let nextAudioTime = 0; // Próximo tempo para agendar audio
288
  let audioScheduledChunks = 0; // Quantos chunks já agendamos
289
  let firstChunkTime = null; // Tempo do primeiro chunk (para latência)
290
  let totalAudioSamples = 0; // Total de samples de audio recebidos
291
+ let currentAudioSource = null; // Referência ao audio source atual
292
+ let audioPlaybackStartTime = 0; // Tempo em que o audio começou a tocar (performance.now)
293
+ let audioExpectedEndTime = 0; // Tempo esperado para o audio terminar (performance.now)
294
+ let progressivePlaybackStarted = false; // Flag para reprodução progressiva
295
+ let audioContextStartTime = 0; // audioContext.currentTime quando começou
296
 
297
+ // Sincronização de transição no nível de frame
298
  let endVideoTimeMs = null; // Tempo para continuar o vídeo idle após fala
299
+ let startFrameIdx = null; // Frame inicial que o Wav2Lip usou
300
+ let endFrameIdx = null; // Frame final que o Wav2Lip usou
301
+ let waitingForFrameSync = false; // Esperando o frame certo para começar
302
+ let frameSyncStartTime = 0; // Quando começou a esperar pelo frame
303
+ const FRAME_SYNC_TIMEOUT = 500; // Timeout máximo em ms para sincronização
304
+ let idleVideoDurationMs = 0; // Duração total do vídeo idle em ms
305
+ let idleVideoTotalFrames = 0; // Total de frames do vídeo idle
306
 
307
  // Renderização unificada no canvas
308
  let renderSource = 'idle'; // 'idle' = video, 'speaking' = frames do servidor
 
329
  let speakingFrameIndex = 0;
330
  let lastSpeakingRenderTime = 0;
331
 
332
+ // Transição direta (sem crossfade para parecer mais natural)
 
 
 
 
333
 
334
  function startUnifiedRenderLoop() {
335
  if (unifiedRenderLoop) return; // Já está rodando
336
 
337
  function renderFrame() {
338
+ // === ESTADO: AGUARDANDO SINCRONIZAÇÃO DE FRAME ===
339
+ if (waitingForFrameSync && startFrameIdx !== null) {
340
+ // Continuar mostrando idle video enquanto aguarda
341
+ if (idleVideo.readyState >= 2) {
342
+ ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
343
+ }
344
+
345
+ // Verificar timeout - se demorou muito, iniciar mesmo assim
346
+ const waitingTime = performance.now() - frameSyncStartTime;
347
+ if (waitingTime > FRAME_SYNC_TIMEOUT) {
348
+ console.log(`Frame sync: TIMEOUT após ${waitingTime.toFixed(0)}ms - iniciando sem sync`);
349
+ waitingForFrameSync = false;
350
+ doStartSpeaking();
351
+ unifiedRenderLoop = requestAnimationFrame(renderFrame);
352
+ return;
353
+ }
354
+
355
+ // Calcular frame atual do vídeo idle
356
+ const currentIdleFrame = Math.floor(idleVideo.currentTime * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
357
+
358
+ // Verificar se chegou no frame alvo (com tolerância de ±2 frames)
359
+ const frameDiff = Math.abs(currentIdleFrame - startFrameIdx);
360
+ const isCloseEnough = frameDiff <= 2 || frameDiff >= (idleVideoTotalFrames - 2);
361
+
362
+ if (isCloseEnough) {
363
+ console.log(`Frame sync: frame atual=${currentIdleFrame}, alvo=${startFrameIdx} - INICIANDO! (${waitingTime.toFixed(0)}ms)`);
364
+ waitingForFrameSync = false;
365
+ doStartSpeaking();
366
+ }
367
+
368
+ unifiedRenderLoop = requestAnimationFrame(renderFrame);
369
+ return;
370
+ }
371
+
372
  if (renderSource === 'idle') {
373
+ // Transição direta - sem fade, apenas troca para o vídeo idle
374
+ // O vídeo idle já está sincronizado no ponto correto (endVideoTimeMs)
375
+ if (idleVideo.readyState >= 2) {
376
+ ctx.drawImage(idleVideo, 0, 0, canvas.width, canvas.height);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  } else {
378
+ // Video não está pronto - tentar recarregar
379
+ console.log(`[IDLE] Video não pronto: readyState=${idleVideo.readyState}, tentando play...`);
380
+ idleVideo.play().catch(e => {});
 
381
  }
382
  } else if (renderSource === 'speaking') {
383
  // Desenha frames do servidor com timing controlado
384
  const now = performance.now();
385
  const elapsed = now - lastSpeakingRenderTime;
386
 
387
+ // === VERIFICAR SE DEVE FINALIZAR ===
388
+
389
+ // Debug: mostrar estado a cada segundo
390
+ if (Math.floor(now / 1000) !== Math.floor(lastSpeakingRenderTime / 1000)) {
391
+ console.log(`[DEBUG] streamDone=${streamDone}, queue=${frameQueue.length}, rendered=${renderedFrames}, audioEnd=${audioExpectedEndTime.toFixed(0)}, now=${now.toFixed(0)}`);
392
+ }
393
+
394
+ // 1. Áudio terminou (tempo esperado passou + offset do usuário)
395
+ const transitionOffset = parseInt(document.getElementById("offset-slider").value) || 0;
396
+ const adjustedEndTime = audioExpectedEndTime + transitionOffset;
397
+ if (audioExpectedEndTime > 0 && now >= adjustedEndTime) {
398
+ console.log(`[FIM] Audio terminou: now=${now.toFixed(0)} >= end=${adjustedEndTime.toFixed(0)} (offset=${transitionOffset})`);
399
  finishPlayback();
400
+ // NÃO fazer return aqui - precisa continuar o render loop para o idle
401
+ }
402
+
403
+ // 2. Stream completo e fila de frames vazia
404
+ else if (streamDone && frameQueue.length === 0) {
405
+ console.log(`[FIM] Stream done + fila vazia: rendered=${renderedFrames}, total=${allFrames.length}`);
406
+ finishPlayback();
407
+ // NÃO fazer return aqui - precisa continuar o render loop para o idle
408
  }
409
 
410
+ // === RENDERIZAR PRÓXIMO FRAME ===
411
  if (elapsed >= syncedFrameInterval && frameQueue.length > 0) {
412
  const frameData = frameQueue.shift();
413
 
 
438
  const bw = totalElapsed > 0 ? (totalBytes / 1024 / totalElapsed) : 0;
439
  updateStatus("bandwidth-status", "", `BW: ${bw.toFixed(0)} KB/s`);
440
 
 
 
 
 
 
 
441
  }
442
  }
443
 
 
456
  }
457
  }
458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  // Iniciar o render loop quando a página carrega
460
  idleVideo.addEventListener('loadeddata', () => {
461
  // Ajustar tamanho do canvas para match do vídeo
462
  canvas.width = idleVideo.videoWidth || 512;
463
  canvas.height = idleVideo.videoHeight || 512;
464
+
465
+ // Capturar duração e calcular total de frames
466
+ idleVideoDurationMs = (idleVideo.duration || 60) * 1000;
467
+ idleVideoTotalFrames = Math.round((idleVideo.duration || 60) * IDLE_VIDEO_FPS);
468
+ console.log(`Video idle carregado: ${canvas.width}x${canvas.height}, ${idleVideo.duration?.toFixed(1)}s, ~${idleVideoTotalFrames} frames`);
469
+
470
+ // Garantir que o vídeo idle está tocando em loop
471
+ idleVideo.loop = true;
472
+ idleVideo.muted = true;
473
+ idleVideo.play().then(() => {
474
+ console.log("Video idle iniciado com sucesso");
475
+ startUnifiedRenderLoop();
476
+ }).catch(e => {
477
+ console.log("Autoplay bloqueado, iniciando render loop mesmo assim:", e);
478
+ startUnifiedRenderLoop();
479
+ });
480
+ });
481
+
482
+ // Fallback: se o vídeo não carregar em 3 segundos, iniciar mesmo assim
483
+ setTimeout(() => {
484
+ if (!unifiedRenderLoop) {
485
+ console.log("Fallback: iniciando render loop após timeout");
486
+ canvas.width = 512;
487
+ canvas.height = 512;
488
+ startUnifiedRenderLoop();
489
+ }
490
+ }, 3000);
491
+
492
+ // Tratamento de erro do vídeo idle
493
+ idleVideo.addEventListener('error', (e) => {
494
+ console.error("Erro carregando vídeo idle:", e);
495
+ log("Erro carregando vídeo idle", "error");
496
+ // Tentar carregar novamente após 2 segundos
497
+ setTimeout(() => {
498
+ console.log("Tentando recarregar vídeo idle...");
499
+ idleVideo.load();
500
+ }, 2000);
501
  });
502
 
503
+ // Quando o vídeo idle termina (não deveria acontecer com loop=true, mas por segurança)
504
+ idleVideo.addEventListener('ended', () => {
505
+ console.log("Video idle ended - reiniciando");
506
+ idleVideo.currentTime = 0;
507
+ idleVideo.play().catch(e => console.log("Erro reiniciando idle:", e));
508
+ });
509
+
510
+ // Quando o vídeo idle para por algum motivo
511
+ idleVideo.addEventListener('pause', () => {
512
+ if (renderSource === 'idle') {
513
+ console.log("Video idle pausou inesperadamente - retomando");
514
+ idleVideo.play().catch(e => console.log("Erro retomando idle:", e));
515
+ }
516
+ });
517
+
518
+ // Forçar carregamento do vídeo idle com cache-busting
519
+ idleVideo.src = `idle.mp4?t=${Date.now()}`;
520
+
521
  function log(msg, type = "msg") {
522
  const logDiv = document.getElementById("log");
523
  const time = new Date().toLocaleTimeString();
 
661
  break;
662
 
663
  case "first_frame":
664
+ // Capturar start_frame_idx para sincronização no nível de frame
665
+ if (data.start_frame_idx !== undefined && data.start_frame_idx !== null) {
666
+ startFrameIdx = data.start_frame_idx;
667
+ console.log(`Sync: start_frame_idx=${startFrameIdx}`);
668
+ }
669
  log(`Primeiro frame (server): ${data.latency_ms}ms`, "status");
670
  break;
671
 
672
  case "done":
673
+ // Salvar índices de frame para sincronização
674
+ if (data.start_frame_idx !== undefined && data.start_frame_idx !== null) {
675
+ startFrameIdx = data.start_frame_idx;
676
+ }
677
+ if (data.end_frame_idx !== undefined && data.end_frame_idx !== null) {
678
+ endFrameIdx = data.end_frame_idx;
679
+ }
680
  if (data.end_video_time_ms !== undefined) {
681
  endVideoTimeMs = data.end_video_time_ms;
 
682
  }
683
+ console.log(`Sync: start_frame=${startFrameIdx}, end_frame=${endFrameIdx}, end_time_ms=${endVideoTimeMs}`);
684
 
685
  const framesCount = data.total_frames || data.frames || allFrames.length;
686
  const bytesInfo = data.bytes_sent ? `${(data.bytes_sent/1024).toFixed(1)}KB` : '';
 
710
  // Acumular frames
711
  allFrames.push({ url, index: frameIndex });
712
 
713
+ // Se já está reproduzindo (progressivo ou normal), adicionar ao buffer
714
+ if (progressivePlaybackStarted || playbackStarted) {
715
  frameQueue.push({ url, index: frameIndex });
716
  document.getElementById("buffer").textContent = frameQueue.length;
717
  }
 
724
  document.getElementById("video-duration").textContent = videoDuration.toFixed(2) + "s";
725
  updateSyncDiff();
726
 
727
+ if (!progressivePlaybackStarted && !playbackStarted) {
728
  document.getElementById("buffer").textContent = received + " (buffering)";
729
  }
730
 
731
  if (received === 1) {
 
 
732
  console.log("Primeiro frame recebido - buffering...");
733
  updateStatus("stream-status", "streaming", "Stream: Buffering...");
734
  }
 
766
  initAudioContext();
767
  }
768
 
 
769
  if (chunkData.length > 0) {
770
+ // Acumular chunk
771
  audioChunks.push(chunkData);
772
 
773
  // Acumular samples para calcular duração
 
781
  document.getElementById("audio-duration").textContent = totalAudioDuration.toFixed(2) + "s";
782
  updateSyncDiff();
783
 
784
+ // === MODO PROGRESSIVO: Agendar chunk imediatamente ===
785
+ if (PROGRESSIVE_MODE && progressivePlaybackStarted) {
786
+ scheduleAudioChunkProgressive(chunkData);
787
+ }
788
+
789
+ console.log(`Audio chunk ${chunkIndex}: ${chunkData.length} bytes, scheduled=${audioScheduledChunks}`);
790
  }
791
 
792
  if (isLast) {
 
795
  console.log(`Todos chunks recebidos: ${audioChunks.length}`);
796
  }
797
 
798
+ // Tentar iniciar playback
799
  tryStartStreamingPlayback();
800
  }
801
 
802
+ function scheduleAudioChunkProgressive(chunkData) {
803
+ // Agendar este chunk de áudio para tocar em sequência
804
+ if (!audioContext) return;
805
+
806
+ try {
807
+ // Converter PCM int16 para float32
808
+ const alignedBuffer = new ArrayBuffer(chunkData.length);
809
+ new Uint8Array(alignedBuffer).set(chunkData);
810
+ const samples = new Int16Array(alignedBuffer);
811
+ const floatSamples = new Float32Array(samples.length);
812
+ for (let i = 0; i < samples.length; i++) {
813
+ floatSamples[i] = samples[i] / 32768;
814
+ }
815
+
816
+ // Criar buffer de audio
817
+ const buffer = audioContext.createBuffer(1, floatSamples.length, 24000);
818
+ buffer.getChannelData(0).set(floatSamples);
819
+
820
+ // Criar source
821
+ const source = audioContext.createBufferSource();
822
+ source.buffer = buffer;
823
+ source.connect(audioContext.destination);
824
+
825
+ // Garantir que não agendamos no passado
826
+ const now = audioContext.currentTime;
827
+ if (nextAudioTime < now) {
828
+ nextAudioTime = now + 0.01;
829
+ }
830
+
831
+ // Agendar para tocar
832
+ source.start(nextAudioTime);
833
+
834
+ // Calcular duração e atualizar próximo tempo
835
+ const chunkDuration = floatSamples.length / 24000;
836
+ nextAudioTime += chunkDuration;
837
+ audioScheduledChunks++;
838
+
839
+ // Atualizar tempo esperado de fim (em performance.now)
840
+ const audioEndContextTime = nextAudioTime;
841
+ const elapsedSinceStart = audioEndContextTime - audioContextStartTime;
842
+ audioExpectedEndTime = audioPlaybackStartTime + (elapsedSinceStart * 1000);
843
+
844
+ } catch (e) {
845
+ console.error("Erro agendando audio chunk:", e);
846
+ }
847
+ }
848
+
849
  function initAudioContext() {
850
  if (!audioContext) {
851
  audioContext = new (window.AudioContext || window.webkitAudioContext)({
 
965
  }
966
 
967
  function tryStartStreamingPlayback() {
968
+ // iniciou playback final? Sair
 
969
  if (playbackStarted) return;
970
 
971
+ const framesCount = allFrames.length;
972
+ const audioChunksCount = audioChunks.length;
 
 
 
 
973
 
974
+ // === MODO PROGRESSIVO ===
975
+ if (PROGRESSIVE_MODE && !progressivePlaybackStarted) {
976
+ // Verificar se temos buffer mínimo para começar
977
+ const hasMinFrames = framesCount >= MIN_FRAMES_TO_START;
978
+ const hasMinAudio = audioChunksCount >= MIN_AUDIO_CHUNKS_TO_START;
979
+
980
+ if (hasMinFrames && hasMinAudio) {
981
+ console.log(`=== INICIANDO REPRODUÇÃO PROGRESSIVA ===`);
982
+ console.log(`Buffer: ${framesCount} frames, ${audioChunksCount} chunks de áudio`);
983
+
984
+ progressivePlaybackStarted = true;
985
+ startProgressivePlayback();
986
+ return;
987
+ } else {
988
+ // Ainda buffering
989
+ const status = `Buffering: ${framesCount}/${MIN_FRAMES_TO_START} frames, ${audioChunksCount}/${MIN_AUDIO_CHUNKS_TO_START} chunks`;
990
+ document.getElementById("buffer").textContent = status;
991
+ return;
992
+ }
993
  }
994
 
995
+ // === STREAM COMPLETO - Ajustar FPS final ===
996
+ if (streamDone && progressivePlaybackStarted && !playbackStarted) {
997
+ playbackStarted = true;
998
+
999
+ // Calcular FPS final baseado na duração real
1000
+ const audioDurationSec = totalAudioSamples / 24000;
1001
+ const calculatedFps = framesCount / audioDurationSec;
1002
+ syncedFrameInterval = 1000 / calculatedFps;
1003
+
1004
+ console.log(`=== AJUSTE FINAL DE SYNC ===`);
1005
+ console.log(`Frames: ${framesCount}, Audio: ${audioDurationSec.toFixed(2)}s`);
1006
+ console.log(`FPS ajustado: ${calculatedFps.toFixed(2)} (interval: ${syncedFrameInterval.toFixed(1)}ms)`);
1007
  return;
1008
  }
1009
 
1010
+ // === MODO TRADICIONAL (sem progressivo) ===
1011
+ if (!PROGRESSIVE_MODE && streamDone) {
1012
+ if (framesCount === 0 || totalAudioSamples === 0) return;
1013
+
1014
+ playbackStarted = true;
1015
+
1016
+ const playbackLatency = Date.now() - startTime;
1017
+ document.getElementById("latency").textContent = playbackLatency + "ms";
1018
+ log(`Latencia: ${playbackLatency}ms`, "status");
1019
+
1020
+ const audioDurationSec = totalAudioSamples / 24000;
1021
+ const calculatedFps = framesCount / audioDurationSec;
1022
+ syncedFrameInterval = 1000 / calculatedFps;
1023
+
1024
+ frameQueue = [...allFrames];
1025
+ renderSource = 'speaking';
1026
+ lastSpeakingRenderTime = performance.now();
1027
+ restartAudioPlayback();
1028
 
1029
+ updateStatus("stream-status", "streaming", "Stream: Reproduzindo");
1030
+ }
1031
+ }
1032
+
1033
+ function startProgressivePlayback() {
1034
+ // Registrar latência
1035
  const playbackLatency = Date.now() - startTime;
1036
  document.getElementById("latency").textContent = playbackLatency + "ms";
1037
  log(`Latencia: ${playbackLatency}ms`, "status");
1038
 
1039
+ // Usar FPS padrão (25fps)
1040
+ syncedFrameInterval = FRAME_INTERVAL;
 
 
 
1041
 
1042
+ console.log(`Iniciando progressivo: ${allFrames.length} frames, ${audioChunks.length} chunks`);
 
 
 
 
1043
 
1044
+ // Copiar frames disponíveis para a fila
1045
  frameQueue = [...allFrames];
1046
  document.getElementById("buffer").textContent = frameQueue.length;
1047
+
1048
+ // === SINCRONIZAÇÃO NO NÍVEL DE FRAME ===
1049
+ // Se temos startFrameIdx, esperar o vídeo idle chegar no frame certo
1050
+ if (startFrameIdx !== null && idleVideoTotalFrames > 0) {
1051
+ waitingForFrameSync = true;
1052
+ frameSyncStartTime = performance.now();
1053
+ console.log(`Frame sync: aguardando frame ${startFrameIdx} de ${idleVideoTotalFrames}`);
1054
+ updateStatus("stream-status", "streaming", `Sync: aguardando frame ${startFrameIdx}...`);
1055
+ // O render loop vai detectar waitingForFrameSync e fazer a transição quando chegar o frame certo
1056
+ return;
1057
+ }
1058
+
1059
+ // Sem frame sync - iniciar imediatamente
1060
+ doStartSpeaking();
1061
+ }
1062
+
1063
+ function doStartSpeaking() {
1064
+ // Mudar para modo speaking
1065
  renderSource = 'speaking';
1066
  lastSpeakingRenderTime = performance.now();
1067
 
1068
+ // Iniciar áudio - agendar todos os chunks que já temos
1069
+ startProgressiveAudio();
 
1070
 
1071
+ updateStatus("stream-status", "streaming", "Stream: Reproduzindo...");
1072
+ }
1073
+
1074
+ function startProgressiveAudio() {
1075
+ if (!audioContext) {
1076
+ initAudioContext();
1077
+ }
1078
+
1079
+ // Registrar tempo de início
1080
+ audioPlaybackStartTime = performance.now();
1081
+ audioContextStartTime = audioContext.currentTime;
1082
+ nextAudioTime = audioContext.currentTime + 0.02; // 20ms de buffer inicial
1083
+
1084
+ // Agendar todos os chunks que já temos
1085
+ for (const chunk of audioChunks) {
1086
+ scheduleAudioChunkProgressive(chunk);
1087
+ }
1088
+
1089
+ console.log(`Audio progressivo: ${audioScheduledChunks} chunks agendados`);
1090
  }
1091
 
1092
  function restartAudioPlayback() {
 
1236
  currentAudioSource = null;
1237
  audioPlaybackStartTime = 0;
1238
  audioExpectedEndTime = 0;
1239
+ audioContextStartTime = 0;
1240
+ progressivePlaybackStarted = false;
1241
 
1242
+ // Reset frame sync state
1243
+ startFrameIdx = null;
1244
+ endFrameIdx = null;
1245
+ endVideoTimeMs = null;
1246
+ waitingForFrameSync = false;
1247
 
1248
  document.getElementById("frames").textContent = "0";
1249
  document.getElementById("rendered").textContent = "0";
 
1260
  document.getElementById("stop-btn").disabled = false;
1261
  updateStatus("stream-status", "", "Stream: Iniciando...");
1262
 
1263
+ // Pegar valores dos controles
1264
+ const quality = parseInt(document.getElementById("quality-slider").value);
1265
+
1266
+ // Calcular start_frame_idx localmente (mesmo cálculo que o Wav2Lip faz)
1267
+ // Isso permite sincronização imediata sem esperar resposta do servidor
1268
+ const localStartFrameIdx = Math.floor((idleVideoTimeMs / 1000) * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
1269
+ startFrameIdx = localStartFrameIdx;
1270
+ console.log(`Frame sync local: start_frame_idx=${startFrameIdx} (de ${idleVideoTotalFrames} frames)`);
1271
+
1272
  log("Enviando: " + text.substring(0, 30) + "...", "status");
1273
 
1274
  // Enviar com o timestamp do vídeo idle para sincronização
 
1276
  action: "generate",
1277
  text: text,
1278
  voice: voice,
1279
+ idle_video_time_ms: idleVideoTimeMs, // Timestamp exato do vídeo idle
1280
+ jpeg_quality: quality // Qualidade do JPEG (50-100)
1281
  }));
1282
  }
1283
 
 
1306
  log(`Finalizado: ${renderedFrames} frames em ${elapsed.toFixed(1)}s`, "msg");
1307
  console.log(`Playback finalizado: ${renderedFrames} frames renderizados`);
1308
 
1309
+ // Transição para idle - calcular posição exata considerando tempo de rede
1310
+ // O servidor envia end_video_time_ms = posição do último frame usado
1311
+ // Precisamos compensar pelo tempo que passou desde que o servidor terminou até agora
1312
+ if (endVideoTimeMs !== null && endVideoTimeMs > 0) {
1313
  const videoDuration = idleVideo.duration || 60;
1314
+ const videoDurationMs = videoDuration * 1000;
1315
+
1316
+ // Calcular quanto tempo passou desde o início (tempo de processamento + rede)
1317
+ const totalElapsedMs = Date.now() - startTime;
1318
+
1319
+ // O áudio tem a duração real - usar isso como referência
1320
+ const audioDurationMs = totalAudioSamples / 24000 * 1000;
1321
+
1322
+ // Tempo extra que passou além da duração do áudio (overhead de rede/processamento)
1323
+ const networkOverheadMs = Math.max(0, totalElapsedMs - audioDurationMs);
1324
+
1325
+ // Posição compensada: end_video_time_ms + overhead de rede
1326
+ const compensatedTimeMs = endVideoTimeMs + networkOverheadMs;
1327
+ const seekTime = (compensatedTimeMs % videoDurationMs) / 1000;
1328
+
1329
+ 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`);
1330
  idleVideo.currentTime = seekTime;
1331
  }
1332
 
 
1334
  idleVideo.loop = true;
1335
  idleVideo.play().catch(e => console.log("Erro ao retomar idle video:", e));
1336
 
1337
+ // Transição direta: speaking idle (sem fade, mais natural)
 
 
 
 
 
 
 
1338
  renderSource = 'idle';
1339
+ console.log("Transição direta para idle");
1340
 
1341
  // Reset estado de audio
1342
  audioExpectedEndTime = 0;
 
1344
 
1345
  // Reset estado geral
1346
  isStreaming = false;
 
1347
  playbackStarted = false;
1348
+ progressivePlaybackStarted = false;
1349
+
1350
+ // Reset frame sync state (manter endVideoTimeMs até aqui pois é usado acima)
1351
+ startFrameIdx = null;
1352
+ endFrameIdx = null;
1353
+ endVideoTimeMs = null;
1354
+ waitingForFrameSync = false;
1355
  document.getElementById("generate-btn").disabled = false;
1356
  document.getElementById("stop-btn").disabled = true;
1357
  updateStatus("stream-status", "online", "Stream: Concluido");
 
1380
  audioDuration = 0;
1381
  audioBuffer = null;
1382
  playbackStarted = false;
1383
+ progressivePlaybackStarted = false;
1384
  nextAudioTime = 0;
1385
  audioScheduledChunks = 0;
1386
  firstChunkTime = null;
1387
  totalAudioSamples = 0;
1388
  audioPlaybackStartTime = 0;
1389
  audioExpectedEndTime = 0;
1390
+ audioContextStartTime = 0;
1391
 
1392
  if (currentAudioSource) {
1393
  try { currentAudioSource.stop(); } catch (e) {}
 
1399
  audioSource = null;
1400
  }
1401
 
1402
+ // Reset frame sync state
1403
+ startFrameIdx = null;
1404
+ endFrameIdx = null;
1405
+ endVideoTimeMs = null;
1406
+ waitingForFrameSync = false;
1407
+
1408
  // Voltar ao video idle (apenas muda a fonte do render loop)
1409
  renderSource = 'idle';
1410
  idleVideo.play().catch(e => {});
 
1422
  }
1423
  }, 30000);
1424
 
1425
+ // === MODO DEMO ===
1426
+ let demoMode = false;
1427
+ let demoTimeout = null;
1428
+ let lastDemoText = "";
1429
+
1430
+ function toggleDemo() {
1431
+ if (demoMode) {
1432
+ stopDemo();
1433
+ } else {
1434
+ startDemo();
1435
+ }
1436
+ }
1437
+
1438
+ function startDemo() {
1439
+ demoMode = true;
1440
+ lastDemoText = document.getElementById("text-input").value.trim();
1441
+
1442
+ if (!lastDemoText) {
1443
+ lastDemoText = "Hello! I am a real-time streaming avatar optimized for low latency.";
1444
+ document.getElementById("text-input").value = lastDemoText;
1445
+ }
1446
+
1447
+ document.getElementById("demo-btn").textContent = "Parar Demo";
1448
+ document.getElementById("demo-btn").style.background = "linear-gradient(135deg, #f44, #c00)";
1449
+ document.getElementById("generate-btn").disabled = true;
1450
+ document.getElementById("text-input").disabled = true;
1451
+
1452
+ log("Modo demo iniciado", "status");
1453
+ console.log("Demo: iniciando ciclo");
1454
+
1455
+ // Iniciar primeiro ciclo
1456
+ demoSpeak();
1457
+ }
1458
+
1459
+ function stopDemo() {
1460
+ demoMode = false;
1461
+ if (demoTimeout) {
1462
+ clearTimeout(demoTimeout);
1463
+ demoTimeout = null;
1464
+ }
1465
+
1466
+ document.getElementById("demo-btn").textContent = "Iniciar Demo";
1467
+ document.getElementById("demo-btn").style.background = "linear-gradient(135deg, #fc0, #f90)";
1468
+ document.getElementById("generate-btn").disabled = false;
1469
+ document.getElementById("text-input").disabled = false;
1470
+
1471
+ stop();
1472
+ log("Modo demo parado", "status");
1473
+ console.log("Demo: parado");
1474
+ }
1475
+
1476
+ function demoSpeak() {
1477
+ if (!demoMode) return;
1478
+
1479
+ console.log("Demo: falando...");
1480
+
1481
+ // Usar o texto configurado
1482
+ const text = document.getElementById("text-input").value.trim() || lastDemoText;
1483
+ const voice = document.getElementById("voice-select").value;
1484
+ const quality = parseInt(document.getElementById("quality-slider").value);
1485
+ const idleVideoTimeMs = getCompensatedIdleVideoTime();
1486
+
1487
+ // Reset estado
1488
+ stopStream();
1489
+ frameQueue = [];
1490
+ allFrames = [];
1491
+ renderedFrames = 0;
1492
+ totalBytes = 0;
1493
+ startTime = Date.now();
1494
+ audioChunks = [];
1495
+ audioChunksComplete = false;
1496
+ streamDone = false;
1497
+ audioDuration = 0;
1498
+ audioBuffer = null;
1499
+ syncedFrameInterval = FRAME_INTERVAL;
1500
+ playbackStarted = false;
1501
+ nextAudioTime = 0;
1502
+ audioScheduledChunks = 0;
1503
+ firstChunkTime = null;
1504
+ totalAudioSamples = 0;
1505
+ currentAudioSource = null;
1506
+ audioPlaybackStartTime = 0;
1507
+ audioExpectedEndTime = 0;
1508
+ audioContextStartTime = 0;
1509
+ progressivePlaybackStarted = false;
1510
+
1511
+ // Reset frame sync state
1512
+ endFrameIdx = null;
1513
+ endVideoTimeMs = null;
1514
+ waitingForFrameSync = false;
1515
+
1516
+ // Calcular start_frame_idx localmente (mesmo cálculo que o Wav2Lip faz)
1517
+ const localStartFrameIdx = Math.floor((idleVideoTimeMs / 1000) * IDLE_VIDEO_FPS) % idleVideoTotalFrames;
1518
+ startFrameIdx = localStartFrameIdx;
1519
+ console.log(`Demo frame sync: start_frame_idx=${startFrameIdx}`);
1520
+
1521
+ isStreaming = true;
1522
+ updateStatus("stream-status", "streaming", "Demo: Falando...");
1523
+
1524
+ ws.send(JSON.stringify({
1525
+ action: "generate",
1526
+ text: text,
1527
+ voice: voice,
1528
+ idle_video_time_ms: idleVideoTimeMs,
1529
+ jpeg_quality: quality
1530
+ }));
1531
+ }
1532
+
1533
+ function demoScheduleNextCycle() {
1534
+ if (!demoMode) return;
1535
+
1536
+ const idleTime = parseInt(document.getElementById("demo-idle-slider").value) * 1000;
1537
+ console.log(`Demo: aguardando ${idleTime}ms em idle...`);
1538
+ updateStatus("stream-status", "online", `Demo: Idle (${idleTime/1000}s)`);
1539
+
1540
+ demoTimeout = setTimeout(() => {
1541
+ if (demoMode) {
1542
+ demoSpeak();
1543
+ }
1544
+ }, idleTime);
1545
+ }
1546
+
1547
+ // Modificar finishPlayback para suportar demo mode
1548
+ const originalFinishPlayback = finishPlayback;
1549
+ finishPlayback = function() {
1550
+ originalFinishPlayback();
1551
+
1552
+ // Se está em modo demo, agendar próximo ciclo
1553
+ if (demoMode) {
1554
+ demoScheduleNextCycle();
1555
+ }
1556
+ };
1557
+
1558
  connectWebSocket();
1559
  </script>
1560
  </body>
interface/index_streaming.html CHANGED
@@ -275,9 +275,8 @@ async function startSyncedPlayback(base64Audio, durationMs) {
275
  audioSource.connect(audioContext.destination);
276
 
277
  audioSource.onended = () => {
 
278
  audioSource = null;
279
- // Transicao imediata quando audio termina
280
- stopPlayback();
281
  };
282
 
283
  // Calcular quantos frames usar baseado na duracao do audio
@@ -304,7 +303,7 @@ async function startSyncedPlayback(base64Audio, durationMs) {
304
  lastRenderedFrame = 0;
305
  }
306
 
307
- // Agora mostrar o canvas (ja com o primeiro frame renderizado)
308
  talkCanvas.style.display = 'block';
309
 
310
  // INICIAR TUDO SINCRONIZADO: audio + video ao mesmo tempo!
@@ -329,12 +328,18 @@ async function startSyncedPlayback(base64Audio, durationMs) {
329
  function renderLoop() {
330
  if (!isPlaying) return;
331
 
 
 
 
 
 
 
332
  const elapsed = performance.now() - playbackStartTime;
333
  // Usar duracao dinamica para sincronizar com audio
334
  const targetFrame = Math.floor(elapsed / dynamicFrameDuration);
335
  const total = totalFrames || frameCount;
336
 
337
- // So renderizar se for um frame diferente do anterior
338
  if (targetFrame !== lastRenderedFrame && targetFrame < total) {
339
  // Acesso O(1) ao frame pelo indice
340
  let frameToRender = frames[targetFrame];
@@ -362,12 +367,8 @@ function renderLoop() {
362
  progress.style.width = (displayedFrame / total * 100) + '%';
363
  }
364
 
365
- // Continuar enquanto tiver audio ou frames
366
- if (audioSource || targetFrame < total) {
367
- animationId = requestAnimationFrame(renderLoop);
368
- } else {
369
- stopPlayback();
370
- }
371
  }
372
 
373
  function stopPlayback() {
@@ -386,21 +387,30 @@ function stopPlayback() {
386
  audioSource = null;
387
  }
388
 
 
 
 
 
 
389
  // Sincronizar idle video para o tempo correto (onde a fala terminou)
390
- if (endVideoTimeMs > 0) {
391
- const targetTime = endVideoTimeMs / 1000;
392
- // Garantir que o tempo esta dentro da duracao do video
393
- if (idleVideo.duration > 0) {
394
- idleVideo.currentTime = targetTime % idleVideo.duration;
395
- console.log(`Idle video sync: ${targetTime.toFixed(2)}s`);
 
 
 
 
396
  }
 
 
 
 
397
  endVideoTimeMs = 0; // Reset para proxima vez
398
  }
399
 
400
- // Esconder canvas, mostrar idle
401
- talkCanvas.style.display = 'none';
402
- ctx.clearRect(0, 0, talkCanvas.width, talkCanvas.height);
403
-
404
  frames = [];
405
  lastRenderedFrame = -1;
406
  setStatus('Pronto', 'ok');
 
275
  audioSource.connect(audioContext.destination);
276
 
277
  audioSource.onended = () => {
278
+ // Apenas marcar como null - o renderLoop vai detectar e parar
279
  audioSource = null;
 
 
280
  };
281
 
282
  // Calcular quantos frames usar baseado na duracao do audio
 
303
  lastRenderedFrame = 0;
304
  }
305
 
306
+ // Mostrar canvas
307
  talkCanvas.style.display = 'block';
308
 
309
  // INICIAR TUDO SINCRONIZADO: audio + video ao mesmo tempo!
 
328
  function renderLoop() {
329
  if (!isPlaying) return;
330
 
331
+ // Se audio terminou, parar imediatamente (transicao instantanea)
332
+ if (!audioSource) {
333
+ stopPlayback();
334
+ return;
335
+ }
336
+
337
  const elapsed = performance.now() - playbackStartTime;
338
  // Usar duracao dinamica para sincronizar com audio
339
  const targetFrame = Math.floor(elapsed / dynamicFrameDuration);
340
  const total = totalFrames || frameCount;
341
 
342
+ // So renderizar se for um frame diferente do anterior e dentro do limite
343
  if (targetFrame !== lastRenderedFrame && targetFrame < total) {
344
  // Acesso O(1) ao frame pelo indice
345
  let frameToRender = frames[targetFrame];
 
367
  progress.style.width = (displayedFrame / total * 100) + '%';
368
  }
369
 
370
+ // Continuar apenas enquanto audio estiver tocando
371
+ animationId = requestAnimationFrame(renderLoop);
 
 
 
 
372
  }
373
 
374
  function stopPlayback() {
 
387
  audioSource = null;
388
  }
389
 
390
+ // Esconder canvas IMEDIATAMENTE para evitar "travadinha"
391
+ // O video idle ja esta tocando por baixo, entao a transicao sera suave
392
+ talkCanvas.style.display = 'none';
393
+ ctx.clearRect(0, 0, talkCanvas.width, talkCanvas.height);
394
+
395
  // Sincronizar idle video para o tempo correto (onde a fala terminou)
396
+ // Isso acontece em background, o usuario ja ve o video idle
397
+ if (endVideoTimeMs > 0 && idleVideo.duration > 0) {
398
+ const targetTime = (endVideoTimeMs / 1000) % idleVideo.duration;
399
+ console.log(`Idle video sync: seeking to ${targetTime.toFixed(2)}s (endVideoTimeMs=${endVideoTimeMs})`);
400
+
401
+ // Fazer o seek em background - video ja esta visivel
402
+ if (idleVideo.fastSeek) {
403
+ idleVideo.fastSeek(targetTime);
404
+ } else {
405
+ idleVideo.currentTime = targetTime;
406
  }
407
+
408
+ // Garantir que esta tocando
409
+ idleVideo.play().catch(() => {});
410
+
411
  endVideoTimeMs = 0; // Reset para proxima vez
412
  }
413
 
 
 
 
 
414
  frames = [];
415
  lastRenderedFrame = -1;
416
  setStatus('Pronto', 'ok');
interface/server.py CHANGED
@@ -1,17 +1,14 @@
1
  """
2
- Interface Server - Streaming Paralelo
3
  Porta: 8080
4
 
5
- Arquitetura (ver CLAUDE.md):
6
- 1. Recebe texto do frontend via WebSocket
7
- 2. Conecta ao Orpheus (8081) e Wav2Lip (8082) EM PARALELO
8
- 3. Recebe chunks de audio do Orpheus e frames do Wav2Lip
9
- 4. Monta chunks (audio Orpheus + frames Wav2Lip) conforme chegam
10
- 5. Envia chunks IMEDIATAMENTE para o frontend
11
 
12
- IMPORTANTE:
13
- - NAO modificar Wav2Lip - ele gera lip sync com eSpeak interno
14
- - Audio final = Orpheus (descartar audio do Wav2Lip)
15
  """
16
  from aiohttp import web
17
  import aiohttp
@@ -20,368 +17,523 @@ import json
20
  import base64
21
  import os
22
  import time
23
- import struct
 
 
 
 
 
 
 
 
24
 
25
  # Configuracao
26
- ORPHEUS_WS = os.getenv("ORPHEUS_WS", "ws://localhost:8081/ws")
27
  WAV2LIP_WS = os.getenv("WAV2LIP_WS", "ws://localhost:8082/ws")
28
  PORT = int(os.getenv("PORT", "8080"))
 
29
 
30
  # Constantes
31
- AUDIO_SAMPLE_RATE = 24000 # Orpheus
32
- VIDEO_FPS = 25 # Wav2Lip
33
- BYTES_PER_SAMPLE = 2 # 16-bit
34
- MS_PER_FRAME = 1000 / VIDEO_FPS # 40ms
35
- AUDIO_BYTES_PER_FRAME = int(MS_PER_FRAME * AUDIO_SAMPLE_RATE * BYTES_PER_SAMPLE / 1000) # 1920 bytes
36
 
37
- routes = web.RouteTableDef()
38
-
39
-
40
- def build_chunk(audio_bytes: bytes, frames: list) -> bytes:
41
- """Monta chunk binario: [audio_size][audio][num_frames][frame_sizes][frames]"""
42
- data = bytearray()
43
- data.extend(struct.pack('>I', len(audio_bytes)))
44
- data.extend(audio_bytes)
45
- data.extend(struct.pack('>I', len(frames)))
46
- for frame in frames:
47
- data.extend(struct.pack('>I', len(frame)))
48
- data.extend(frame)
49
- return bytes(data)
50
-
51
-
52
- class ParallelStreamingSession:
53
- """Streaming paralelo: Orpheus (audio) + Wav2Lip (frames)"""
54
-
55
- def __init__(self, client_ws):
56
- self.client_ws = client_ws
57
- self.is_running = False
58
- self.start_time = None
59
-
60
- # Buffers compartilhados
61
- self.audio_buffer = bytearray()
62
- self.frame_buffer = []
63
-
64
- # Locks para acesso thread-safe
65
- self.buffer_lock = asyncio.Lock()
66
-
67
- # Estados
68
- self.orpheus_done = False
69
- self.wav2lip_done = False
70
- self.first_chunk_sent = False
71
- self.chunk_index = 0
72
-
73
- # Estatisticas
74
- self.total_audio_bytes = 0
75
- self.total_frames = 0
76
- self.chunks_sent = 0
77
-
78
- async def send_status(self, message: str):
79
- """Envia status para o cliente."""
80
- try:
81
- if not self.client_ws.closed:
82
- await self.client_ws.send_json({"type": "status", "message": message})
83
- except:
84
- pass
85
-
86
- async def send_chunk(self, audio: bytes, frames: list):
87
- """Monta e envia chunk para o cliente."""
88
- if self.client_ws.closed or not audio or not frames:
89
- return
90
-
91
- try:
92
- chunk_data = build_chunk(audio, frames)
93
- chunk_b64 = base64.b64encode(chunk_data).decode()
94
- audio_ms = len(audio) / BYTES_PER_SAMPLE / AUDIO_SAMPLE_RATE * 1000
95
-
96
- await self.client_ws.send_json({
97
- "type": "chunk",
98
- "chunk_index": self.chunk_index,
99
- "audio_size": len(audio),
100
- "audio_duration_ms": int(audio_ms),
101
- "num_frames": len(frames),
102
- "data": chunk_b64
103
- })
104
-
105
- self.chunk_index += 1
106
- self.chunks_sent += 1
107
-
108
- if not self.first_chunk_sent:
109
- self.first_chunk_sent = True
110
- ttfb = int((time.time() - self.start_time) * 1000)
111
- print(f"[Stream] Primeiro chunk: TTFB={ttfb}ms")
112
- await self.client_ws.send_json({"type": "stream_start", "ttfb_ms": ttfb})
113
-
114
- except Exception as e:
115
- print(f"[Stream] Erro enviando chunk: {e}")
116
-
117
- async def try_send_chunks(self):
118
- """Tenta montar e enviar chunks com dados disponiveis."""
119
- async with self.buffer_lock:
120
- # Enquanto tiver 1 frame + audio correspondente
121
- while len(self.frame_buffer) > 0 and len(self.audio_buffer) >= AUDIO_BYTES_PER_FRAME:
122
- # Pega 1 frame
123
- frame = self.frame_buffer.pop(0)
124
-
125
- # Pega audio correspondente (~40ms = 1920 bytes)
126
- audio = bytes(self.audio_buffer[:AUDIO_BYTES_PER_FRAME])
127
- del self.audio_buffer[:AUDIO_BYTES_PER_FRAME]
128
-
129
- await self.send_chunk(audio, [frame])
130
-
131
- # Se ambos terminaram, enviar o que sobrou
132
- if self.orpheus_done and self.wav2lip_done:
133
- if self.frame_buffer and self.audio_buffer:
134
- # Dividir audio restante pelos frames restantes
135
- audio_per_frame = len(self.audio_buffer) // len(self.frame_buffer) if self.frame_buffer else 0
136
- audio_per_frame = max(audio_per_frame, 2) # Minimo 2 bytes
137
- audio_per_frame = audio_per_frame - (audio_per_frame % 2) # Alinhamento 16-bit
138
-
139
- while self.frame_buffer and self.audio_buffer:
140
- frame = self.frame_buffer.pop(0)
141
- audio_size = min(audio_per_frame, len(self.audio_buffer))
142
- if not self.frame_buffer: # Ultimo frame pega todo o resto
143
- audio_size = len(self.audio_buffer)
144
- audio = bytes(self.audio_buffer[:audio_size])
145
- del self.audio_buffer[:audio_size]
146
- await self.send_chunk(audio, [frame])
147
-
148
- elif self.frame_buffer:
149
- # Frames sem audio - enviar com audio vazio
150
- for frame in self.frame_buffer:
151
- await self.send_chunk(b'', [frame])
152
- self.frame_buffer.clear()
153
-
154
- async def stream_orpheus(self, text: str, voice: str):
155
- """Conecta ao Orpheus e recebe chunks de audio."""
156
- try:
157
- print(f"[Orpheus] Conectando a {ORPHEUS_WS}...")
158
- async with aiohttp.ClientSession() as session:
159
- ws = await session.ws_connect(ORPHEUS_WS, timeout=aiohttp.ClientWSTimeout(ws_close=120))
160
-
161
- # Enviar requisicao
162
- await ws.send_json({
163
- "action": "synthesize",
164
- "text": text,
165
- "voice": voice,
166
- "stream": True
167
- })
168
- print(f"[Orpheus] Requisicao enviada")
169
-
170
- async for msg in ws:
171
- if not self.is_running:
172
- break
173
 
174
- if msg.type == aiohttp.WSMsgType.TEXT:
175
- data = json.loads(msg.data)
176
- msg_type = data.get("type", "")
177
 
178
- if msg_type == "audio_chunk":
179
- # Decodificar e adicionar ao buffer
180
- audio_b64 = data.get("audio", "")
181
- audio_bytes = base64.b64decode(audio_b64)
182
 
183
- async with self.buffer_lock:
184
- self.audio_buffer.extend(audio_bytes)
185
- self.total_audio_bytes += len(audio_bytes)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
- # Tentar enviar chunks
188
- await self.try_send_chunks()
189
 
190
- chunk_idx = data.get("chunk_index", 0)
191
- if chunk_idx == 1:
192
- print(f"[Orpheus] Primeiro chunk de audio recebido")
 
193
 
194
- elif msg_type == "done":
195
- total = data.get("total_bytes", 0)
196
- print(f"[Orpheus] Concluido: {total} bytes")
197
- break
198
 
199
- elif msg_type == "error":
200
- print(f"[Orpheus] Erro: {data.get('message')}")
201
- break
202
 
203
- elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
204
- break
 
205
 
206
- await ws.close()
 
207
 
208
- except Exception as e:
209
- print(f"[Orpheus] Erro: {e}")
210
- import traceback
211
- traceback.print_exc()
212
 
213
- finally:
214
- self.orpheus_done = True
215
- await self.try_send_chunks()
216
 
217
- async def stream_wav2lip(self, text: str, voice: str):
218
- """Conecta ao Wav2Lip e recebe frames."""
219
- try:
220
- print(f"[Wav2Lip] Conectando a {WAV2LIP_WS}...")
221
- async with aiohttp.ClientSession() as session:
222
- ws = await session.ws_connect(WAV2LIP_WS, timeout=aiohttp.ClientWSTimeout(ws_close=120))
223
-
224
- # Enviar requisicao
225
- await ws.send_json({
226
- "action": "generate",
227
- "text": text,
228
- "voice": voice
229
- })
230
- print(f"[Wav2Lip] Requisicao enviada")
231
-
232
- async for msg in ws:
233
- if not self.is_running:
234
- break
235
 
236
- if msg.type == aiohttp.WSMsgType.TEXT:
237
- data = json.loads(msg.data)
238
- msg_type = data.get("type", "")
239
 
240
- if msg_type == "frame":
241
- # Decodificar e adicionar ao buffer
242
- frame_b64 = data.get("frame", "")
243
- frame_bytes = base64.b64decode(frame_b64)
244
 
245
- async with self.buffer_lock:
246
- self.frame_buffer.append(frame_bytes)
247
- self.total_frames += 1
 
 
 
248
 
249
- # Tentar enviar chunks
250
- await self.try_send_chunks()
 
 
 
 
251
 
252
- frame_idx = data.get("index", 0)
253
- if frame_idx == 0:
254
- print(f"[Wav2Lip] Primeiro frame recebido")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
- elif msg_type == "status":
257
- print(f"[Wav2Lip] {data.get('message')}")
 
258
 
259
- elif msg_type == "first_chunk":
260
- print(f"[Wav2Lip] eSpeak latency: {data.get('latency_ms')}ms")
261
 
262
- elif msg_type == "full_audio":
263
- # Ignorar - usamos audio do Orpheus
264
- print(f"[Wav2Lip] Audio ignorado (usando Orpheus)")
 
265
 
266
- elif msg_type == "done":
267
- frames = data.get("frames", 0)
268
- print(f"[Wav2Lip] Concluido: {frames} frames")
269
- break
270
 
271
- elif msg_type == "error":
272
- print(f"[Wav2Lip] Erro: {data.get('message')}")
273
- await self.send_status(f"Erro: {data.get('message')}")
274
- break
 
 
 
275
 
276
- elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
277
- break
 
 
 
278
 
279
- await ws.close()
 
280
 
281
- except Exception as e:
282
- print(f"[Wav2Lip] Erro: {e}")
283
- import traceback
284
- traceback.print_exc()
 
285
 
286
- finally:
287
- self.wav2lip_done = True
288
- await self.try_send_chunks()
289
 
290
- async def run(self, text: str, voice: str):
291
- """Executa streaming paralelo."""
292
- self.is_running = True
293
- self.start_time = time.time()
294
 
295
- await self.send_status("Conectando aos servicos...")
 
 
 
 
 
 
 
296
 
297
- try:
298
- # Conectar Orpheus e Wav2Lip EM PARALELO
299
- orpheus_task = asyncio.create_task(self.stream_orpheus(text, voice))
300
- wav2lip_task = asyncio.create_task(self.stream_wav2lip(text, voice))
301
 
302
- # Aguardar ambos terminarem
303
- await asyncio.gather(orpheus_task, wav2lip_task)
304
 
305
- # Enviar chunks restantes
306
- await self.try_send_chunks()
307
 
308
- except Exception as e:
309
- print(f"[Stream] Erro: {e}")
310
- await self.client_ws.send_json({"type": "error", "message": str(e)})
311
- return
 
 
312
 
313
- elapsed = time.time() - self.start_time
314
- print(f"[Stream] Concluido: {elapsed:.2f}s, {self.chunks_sent} chunks, {self.total_frames} frames, {self.total_audio_bytes} bytes")
 
315
 
316
  try:
317
- await self.client_ws.send_json({
318
- "type": "done",
319
- "total_chunks": self.chunks_sent,
320
- "total_frames": self.total_frames,
321
- "total_audio_bytes": self.total_audio_bytes,
322
- "elapsed_ms": int(elapsed * 1000)
 
 
 
 
 
 
323
  })
324
- except:
325
- pass
326
-
327
- def stop(self):
328
- self.is_running = False
329
-
330
 
331
- @routes.get("/ws")
332
- async def websocket_handler(request):
333
- ws = web.WebSocketResponse()
334
- await ws.prepare(request)
335
 
336
- print("Cliente conectado")
337
- session = None
338
-
339
- try:
340
- async for msg in ws:
341
- if msg.type == aiohttp.WSMsgType.TEXT:
342
- try:
343
  data = json.loads(msg.data)
344
- action = data.get("action", "")
345
-
346
- if action == "generate":
347
- text = data.get("text", "").strip()
348
- voice = data.get("voice", "tara")
349
-
350
- if not text:
351
- await ws.send_json({"type": "error", "message": "Text required"})
352
- continue
353
-
354
- print(f"Gerando: '{text[:50]}...' voice={voice}")
355
-
356
- if session:
357
- session.stop()
 
 
 
 
 
358
 
359
- session = ParallelStreamingSession(ws)
360
- await session.run(text, voice)
 
361
 
362
- elif action == "stop":
363
- if session:
364
- session.stop()
365
- await ws.send_json({"type": "stopped"})
366
 
367
- elif action == "ping":
368
- await ws.send_json({"type": "pong"})
369
 
370
- except json.JSONDecodeError:
371
- await ws.send_json({"type": "error", "message": "Invalid JSON"})
 
 
 
372
 
373
- elif msg.type == aiohttp.WSMsgType.ERROR:
374
- print(f"WebSocket error: {ws.exception()}")
375
- break
376
 
377
- except Exception as e:
378
- print(f"Erro: {e}")
379
- finally:
380
- if session:
381
- session.stop()
382
- print("Cliente desconectado")
383
 
384
- return ws
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
 
386
 
387
  @routes.get("/")
@@ -389,11 +541,6 @@ async def index(request):
389
  return web.FileResponse(os.path.join(os.path.dirname(__file__), "index.html"))
390
 
391
 
392
- @routes.get("/idle.mp4")
393
- async def idle_video(request):
394
- return web.FileResponse(os.path.join(os.path.dirname(__file__), "idle.mp4"))
395
-
396
-
397
  @routes.get("/{filename}")
398
  async def static_file(request):
399
  filename = request.match_info["filename"]
@@ -405,21 +552,41 @@ async def static_file(request):
405
 
406
  @routes.get("/health")
407
  async def health(request):
408
- return web.json_response({"status": "ok", "mode": "parallel_streaming"})
 
 
 
 
 
 
 
 
 
 
 
409
 
410
 
411
  app = web.Application()
412
  app.add_routes(routes)
 
413
 
414
 
415
  if __name__ == "__main__":
416
  print("=" * 50)
417
- print("Interface Server - Streaming Paralelo")
418
  print("=" * 50)
419
  print(f"Porta: {PORT}")
420
- print(f"Orpheus: {ORPHEUS_WS}")
421
  print(f"Wav2Lip: {WAV2LIP_WS}")
422
- print(f"Audio: {AUDIO_SAMPLE_RATE}Hz, {AUDIO_BYTES_PER_FRAME} bytes/frame")
423
- print(f"Video: {VIDEO_FPS}fps, {MS_PER_FRAME}ms/frame")
424
  print("=" * 50)
 
 
 
 
 
 
 
 
 
 
425
  web.run_app(app, host="0.0.0.0", port=PORT)
 
1
  """
2
+ Interface Server - WebRTC Streaming com VP9
3
  Porta: 8080
4
 
5
+ Arquitetura:
6
+ 1. Cliente conecta via WebRTC (signaling por WebSocket)
7
+ 2. Servidor envia stream de video VP9 + audio Opus
8
+ 3. Fusao idle/lip-sync feita no backend
9
+ 4. Frontend apenas renderiza o <video>
 
10
 
11
+ Framework: aiortc (https://github.com/aiortc/aiortc)
 
 
12
  """
13
  from aiohttp import web
14
  import aiohttp
 
17
  import base64
18
  import os
19
  import time
20
+ import uuid
21
+ import fractions
22
+ import numpy as np
23
+ from av import VideoFrame, AudioFrame
24
+ from aiortc import RTCPeerConnection, RTCSessionDescription, MediaStreamTrack, RTCConfiguration, RTCIceServer
25
+ from aiortc.contrib.media import MediaRelay
26
+ import cv2
27
+ import subprocess
28
+ import tempfile
29
 
30
  # Configuracao
 
31
  WAV2LIP_WS = os.getenv("WAV2LIP_WS", "ws://localhost:8082/ws")
32
  PORT = int(os.getenv("PORT", "8080"))
33
+ IDLE_VIDEO = os.path.join(os.path.dirname(__file__), "idle.mp4")
34
 
35
  # Constantes
36
+ VIDEO_FPS = 25
37
+ AUDIO_SAMPLE_RATE = 24000
38
+ VIDEO_TIME_BASE = fractions.Fraction(1, VIDEO_FPS)
39
+ AUDIO_TIME_BASE = fractions.Fraction(1, AUDIO_SAMPLE_RATE)
 
40
 
41
+ # Cache global
42
+ idle_frames_cache = []
43
+ pcs = set() # Track peer connections
44
+ relay = MediaRelay()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
+ routes = web.RouteTableDef()
 
 
47
 
 
 
 
 
48
 
49
+ def calculate_frame_difference(frame1, frame2):
50
+ """Calcula diferenca entre dois frames (0 = identicos, 1 = muito diferentes)."""
51
+ if frame1 is None or frame2 is None:
52
+ return 1.0
53
+
54
+ # Converter para grayscale se necessario
55
+ if len(frame1.shape) == 3:
56
+ gray1 = cv2.cvtColor(frame1, cv2.COLOR_RGB2GRAY)
57
+ else:
58
+ gray1 = frame1
59
+
60
+ if len(frame2.shape) == 3:
61
+ gray2 = cv2.cvtColor(frame2, cv2.COLOR_RGB2GRAY)
62
+ else:
63
+ gray2 = frame2
64
+
65
+ # Redimensionar para mesma resolucao se necessario
66
+ if gray1.shape != gray2.shape:
67
+ gray2 = cv2.resize(gray2, (gray1.shape[1], gray1.shape[0]))
68
+
69
+ # Calcular diferenca
70
+ diff = cv2.absdiff(gray1, gray2)
71
+ return np.mean(diff) / 255.0
72
+
73
+
74
+ def find_best_matching_idle_frame(last_speak_frame, idle_frames, sample_step=10):
75
+ """
76
+ Encontra o frame idle mais similar ao ultimo frame de fala.
77
+ Usa amostragem para ser mais rapido.
78
+ """
79
+ if not idle_frames or last_speak_frame is None:
80
+ return 0, float('inf')
81
+
82
+ best_idx = 0
83
+ best_diff = float('inf')
84
+
85
+ # Primeira passada: amostragem grosseira
86
+ for i in range(0, len(idle_frames), sample_step):
87
+ diff = calculate_frame_difference(last_speak_frame, idle_frames[i])
88
+ if diff < best_diff:
89
+ best_diff = diff
90
+ best_idx = i
91
+
92
+ # Segunda passada: refinamento na regiao
93
+ start = max(0, best_idx - sample_step)
94
+ end = min(len(idle_frames), best_idx + sample_step)
95
+
96
+ for i in range(start, end):
97
+ diff = calculate_frame_difference(last_speak_frame, idle_frames[i])
98
+ if diff < best_diff:
99
+ best_diff = diff
100
+ best_idx = i
101
+
102
+ return best_idx, best_diff
103
+
104
+
105
+ def trim_high_motion_frames(frames, threshold_multiplier=1.0, max_trim=20):
106
+ """
107
+ Remove frames do final que tem movimento muito alto (saltos).
108
+ """
109
+ if len(frames) < 20:
110
+ return frames, None
111
+
112
+ # Calcular diferencas entre frames consecutivos (ultimos 20)
113
+ last_n = min(20, len(frames) - 1)
114
+ differences = []
115
+ for i in range(len(frames) - last_n, len(frames)):
116
+ if i > 0:
117
+ diff = calculate_frame_difference(frames[i-1], frames[i])
118
+ differences.append((i, diff))
119
+
120
+ if not differences:
121
+ return frames, frames[-1] if frames else None
122
+
123
+ # Calcular media e desvio padrao
124
+ diffs = [d[1] for d in differences]
125
+ mean_diff = np.mean(diffs)
126
+ std_diff = np.std(diffs)
127
+
128
+ # Threshold agressivo
129
+ threshold = mean_diff + threshold_multiplier * std_diff
130
+ min_threshold = 0.7
131
+ if threshold > min_threshold:
132
+ threshold = min_threshold
133
+
134
+ # Encontrar onde comecam os frames problematicos
135
+ trim_from = len(frames)
136
+ frames_removed = 0
137
+
138
+ for i in range(len(differences) - 1, -1, -1):
139
+ idx, diff = differences[i]
140
+ if diff > threshold:
141
+ trim_from = idx
142
+ frames_removed += 1
143
+ if frames_removed >= max_trim:
144
+ break
145
+ else:
146
+ break
147
 
148
+ frames_to_trim = len(frames) - trim_from
 
149
 
150
+ if frames_to_trim > 0 and frames_to_trim <= max_trim:
151
+ print(f"[Trim] Removendo {frames_to_trim} frames problematicos")
152
+ trimmed_frames = frames[:trim_from]
153
+ return trimmed_frames, trimmed_frames[-1] if trimmed_frames else None
154
 
155
+ return frames, frames[-1] if frames else None
 
 
 
156
 
 
 
 
157
 
158
+ def load_idle_frames():
159
+ """Carrega frames do idle.mp4 como arrays numpy."""
160
+ global idle_frames_cache
161
 
162
+ if idle_frames_cache:
163
+ return idle_frames_cache
164
 
165
+ if not os.path.exists(IDLE_VIDEO):
166
+ print(f"[Idle] Arquivo nao encontrado: {IDLE_VIDEO}")
167
+ return []
 
168
 
169
+ print(f"[Idle] Carregando frames de {IDLE_VIDEO}...")
 
 
170
 
171
+ cap = cv2.VideoCapture(IDLE_VIDEO)
172
+ while True:
173
+ ret, frame = cap.read()
174
+ if not ret:
175
+ break
176
+ # Converter BGR para RGB
177
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
178
+ idle_frames_cache.append(frame_rgb)
179
+ cap.release()
 
 
 
 
 
 
 
 
 
180
 
181
+ print(f"[Idle] Carregados {len(idle_frames_cache)} frames")
182
+ return idle_frames_cache
 
183
 
 
 
 
 
184
 
185
+ class AvatarVideoTrack(MediaStreamTrack):
186
+ """
187
+ Track de video que envia frames do avatar.
188
+ Alterna entre idle e lip-sync conforme necessario.
189
+ """
190
+ kind = "video"
191
 
192
+ def __init__(self):
193
+ super().__init__()
194
+ self.idle_frames = load_idle_frames()
195
+ self.current_idx = 0
196
+ self.frame_count = 0
197
+ self.start_time = None
198
 
199
+ # Estado
200
+ self.is_speaking = False
201
+ self.speaking_frames = []
202
+ self.speaking_idx = 0
203
+ self.best_idle_idx = None # Frame idle para transicao suave
204
+
205
+ # Dimensoes do video
206
+ if self.idle_frames:
207
+ self.height, self.width = self.idle_frames[0].shape[:2]
208
+ else:
209
+ self.width, self.height = 640, 480
210
+
211
+ print(f"[VideoTrack] Inicializado: {self.width}x{self.height} @ {VIDEO_FPS}fps")
212
+
213
+ async def recv(self):
214
+ """Retorna o proximo frame de video."""
215
+ if self.start_time is None:
216
+ self.start_time = time.time()
217
+
218
+ # Calcular pts baseado no tempo
219
+ pts = int(self.frame_count * VIDEO_TIME_BASE.denominator / VIDEO_FPS)
220
+ self.frame_count += 1
221
+
222
+ # Escolher frame: speaking ou idle
223
+ if self.is_speaking and self.speaking_frames:
224
+ if self.speaking_idx < len(self.speaking_frames):
225
+ frame_data = self.speaking_frames[self.speaking_idx]
226
+ self.speaking_idx += 1
227
+ else:
228
+ # Acabou a fala, voltar ao idle no best_idle_idx
229
+ self.is_speaking = False
230
+ self.speaking_frames = []
231
+ self.speaking_idx = 0
232
+
233
+ # Usar o frame idle pre-calculado para transicao suave
234
+ if self.best_idle_idx is not None and self.idle_frames:
235
+ self.current_idx = self.best_idle_idx
236
+ self.best_idle_idx = None
237
+ print(f"[VideoTrack] Transicao suave -> idle frame {self.current_idx}")
238
+
239
+ frame_data = self.idle_frames[self.current_idx % len(self.idle_frames)]
240
+ self.current_idx += 1
241
+ elif self.idle_frames:
242
+ frame_data = self.idle_frames[self.current_idx % len(self.idle_frames)]
243
+ self.current_idx += 1
244
+ else:
245
+ # Fallback: frame preto
246
+ frame_data = np.zeros((self.height, self.width, 3), dtype=np.uint8)
247
+
248
+ # Criar VideoFrame
249
+ frame = VideoFrame.from_ndarray(frame_data, format="rgb24")
250
+ frame.pts = pts
251
+ frame.time_base = VIDEO_TIME_BASE
252
+
253
+ # Manter timing de 25fps
254
+ elapsed = time.time() - self.start_time
255
+ expected = self.frame_count / VIDEO_FPS
256
+ if expected > elapsed:
257
+ await asyncio.sleep(expected - elapsed)
258
+
259
+ return frame
260
+
261
+ def set_speaking_frames(self, frames):
262
+ """Define frames de lip-sync para reproduzir com transicao suave."""
263
+ # Aplicar trim de frames problematicos
264
+ trimmed_frames, last_frame = trim_high_motion_frames(frames)
265
+
266
+ # Encontrar o melhor frame idle para transicao suave
267
+ if last_frame is not None and self.idle_frames:
268
+ best_idx, best_diff = find_best_matching_idle_frame(
269
+ last_frame, self.idle_frames, sample_step=10
270
+ )
271
+ self.best_idle_idx = best_idx
272
+ print(f"[VideoTrack] Best match: idle frame {best_idx} (diff: {best_diff:.2f})")
273
+ else:
274
+ self.best_idle_idx = None
275
+
276
+ self.speaking_frames = trimmed_frames
277
+ self.speaking_idx = 0
278
+ self.is_speaking = True
279
+ print(f"[VideoTrack] Speaking: {len(trimmed_frames)} frames (original: {len(frames)})")
280
+
281
+
282
+ class AvatarAudioTrack(MediaStreamTrack):
283
+ """
284
+ Track de audio que envia silencio ou audio do Orpheus.
285
+ """
286
+ kind = "audio"
287
+
288
+ def __init__(self):
289
+ super().__init__()
290
+ self.sample_rate = AUDIO_SAMPLE_RATE
291
+ self.samples_per_frame = 960 # 40ms @ 24kHz
292
+ self.frame_count = 0
293
+ self.start_time = None
294
 
295
+ # Buffer de audio
296
+ self.audio_buffer = []
297
+ self.buffer_idx = 0
298
 
299
+ print(f"[AudioTrack] Inicializado: {self.sample_rate}Hz")
 
300
 
301
+ async def recv(self):
302
+ """Retorna o proximo frame de audio."""
303
+ if self.start_time is None:
304
+ self.start_time = time.time()
305
 
306
+ pts = self.frame_count * self.samples_per_frame
307
+ self.frame_count += 1
 
 
308
 
309
+ # Pegar audio do buffer ou silencio
310
+ if self.audio_buffer and self.buffer_idx < len(self.audio_buffer):
311
+ samples = self.audio_buffer[self.buffer_idx]
312
+ self.buffer_idx += 1
313
+ else:
314
+ # Silencio
315
+ samples = np.zeros(self.samples_per_frame, dtype=np.int16)
316
 
317
+ # Criar AudioFrame
318
+ frame = AudioFrame(format="s16", layout="mono", samples=len(samples))
319
+ frame.sample_rate = self.sample_rate
320
+ frame.pts = pts
321
+ frame.time_base = AUDIO_TIME_BASE
322
 
323
+ # Copiar samples
324
+ frame.planes[0].update(samples.tobytes())
325
 
326
+ # Manter timing
327
+ elapsed = time.time() - self.start_time
328
+ expected = self.frame_count * self.samples_per_frame / self.sample_rate
329
+ if expected > elapsed:
330
+ await asyncio.sleep(expected - elapsed)
331
 
332
+ return frame
 
 
333
 
334
+ def set_audio(self, pcm_data):
335
+ """Define audio PCM para reproduzir."""
336
+ # Converter bytes para numpy array
337
+ samples = np.frombuffer(pcm_data, dtype=np.int16)
338
 
339
+ # Dividir em frames de 40ms
340
+ self.audio_buffer = []
341
+ for i in range(0, len(samples), self.samples_per_frame):
342
+ chunk = samples[i:i + self.samples_per_frame]
343
+ if len(chunk) < self.samples_per_frame:
344
+ # Padding com zeros
345
+ chunk = np.pad(chunk, (0, self.samples_per_frame - len(chunk)))
346
+ self.audio_buffer.append(chunk)
347
 
348
+ self.buffer_idx = 0
349
+ print(f"[AudioTrack] Audio: {len(self.audio_buffer)} frames ({len(pcm_data)} bytes)")
 
 
350
 
 
 
351
 
352
+ class AvatarSession:
353
+ """Gerencia uma sessao WebRTC com o cliente."""
354
 
355
+ def __init__(self, pc, video_track, audio_track):
356
+ self.pc = pc
357
+ self.video_track = video_track
358
+ self.audio_track = audio_track
359
+ self.wav2lip_ws = None
360
+ self.wav2lip_session = None
361
 
362
+ async def generate(self, text: str, voice: str):
363
+ """Gera fala com lip-sync via Wav2Lip."""
364
+ print(f"[Session] Gerando: '{text[:50]}...'")
365
 
366
  try:
367
+ # Conectar ao Wav2Lip
368
+ self.wav2lip_session = aiohttp.ClientSession()
369
+ self.wav2lip_ws = await self.wav2lip_session.ws_connect(
370
+ WAV2LIP_WS,
371
+ timeout=aiohttp.ClientWSTimeout(ws_close=120)
372
+ )
373
+
374
+ # Enviar requisicao
375
+ await self.wav2lip_ws.send_json({
376
+ "action": "generate",
377
+ "text": text,
378
+ "voice": voice
379
  })
 
 
 
 
 
 
380
 
381
+ speaking_frames = []
382
+ audio_data = b''
 
 
383
 
384
+ # Receber frames e audio
385
+ async for msg in self.wav2lip_ws:
386
+ if msg.type == aiohttp.WSMsgType.TEXT:
 
 
 
 
387
  data = json.loads(msg.data)
388
+ msg_type = data.get("type", "")
389
+
390
+ if msg_type == "frame":
391
+ frame_b64 = data.get("frame", "")
392
+ if frame_b64:
393
+ # Decodificar JPEG para numpy
394
+ jpeg_data = base64.b64decode(frame_b64)
395
+ nparr = np.frombuffer(jpeg_data, np.uint8)
396
+ frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
397
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
398
+ speaking_frames.append(frame_rgb)
399
+
400
+ elif msg_type == "full_audio":
401
+ audio_b64 = data.get("audio", "")
402
+ if audio_b64:
403
+ audio_data = base64.b64decode(audio_b64)
404
+
405
+ elif msg_type == "done":
406
+ break
407
 
408
+ elif msg_type == "error":
409
+ print(f"[Session] Erro Wav2Lip: {data.get('message')}")
410
+ break
411
 
412
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
413
+ break
 
 
414
 
415
+ await self.wav2lip_ws.close()
416
+ await self.wav2lip_session.close()
417
 
418
+ # Aplicar frames e audio aos tracks
419
+ if speaking_frames:
420
+ self.video_track.set_speaking_frames(speaking_frames)
421
+ if audio_data:
422
+ self.audio_track.set_audio(audio_data)
423
 
424
+ print(f"[Session] Gerado: {len(speaking_frames)} frames, {len(audio_data)} bytes audio")
 
 
425
 
426
+ except Exception as e:
427
+ print(f"[Session] Erro: {e}")
428
+ import traceback
429
+ traceback.print_exc()
 
 
430
 
431
+ async def close(self):
432
+ """Fecha a sessao."""
433
+ if self.wav2lip_ws and not self.wav2lip_ws.closed:
434
+ await self.wav2lip_ws.close()
435
+ if self.wav2lip_session:
436
+ await self.wav2lip_session.close()
437
+
438
+
439
+ # Armazenar sessoes ativas
440
+ sessions = {}
441
+
442
+
443
+ @routes.post("/offer")
444
+ async def offer(request):
445
+ """Recebe offer SDP do cliente e retorna answer."""
446
+ params = await request.json()
447
+ offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])
448
+
449
+ # Configurar ICE servers (STUN + TURN publicos)
450
+ ice_servers = [
451
+ RTCIceServer(urls=["stun:stun.l.google.com:19302"]),
452
+ # Servidores TURN com múltiplas URLs
453
+ RTCIceServer(
454
+ urls=[
455
+ "turn:openrelay.metered.ca:80",
456
+ "turn:openrelay.metered.ca:443",
457
+ "turn:openrelay.metered.ca:443?transport=tcp"
458
+ ],
459
+ username="openrelayproject",
460
+ credential="openrelayproject"
461
+ ),
462
+ # TURN alternativo (Twilio)
463
+ RTCIceServer(
464
+ urls=["turn:global.turn.twilio.com:3478?transport=udp"],
465
+ username="f4b4035eaa76f4a55de5f4351567653ee4ff6fa97b50b6b334fcc1be9c27212d",
466
+ credential="w1uxM55V9yVoqyVFjt+mxDBV0F87AUCemaYVQGxsPLw="
467
+ ),
468
+ ]
469
+
470
+ config = RTCConfiguration(iceServers=ice_servers)
471
+ pc = RTCPeerConnection(configuration=config)
472
+ pc_id = str(uuid.uuid4())
473
+ pcs.add(pc)
474
+
475
+ print(f"[WebRTC] Nova conexao: {pc_id}")
476
+
477
+ # Criar tracks
478
+ video_track = AvatarVideoTrack()
479
+ audio_track = AvatarAudioTrack()
480
+
481
+ # Adicionar tracks ao peer connection
482
+ pc.addTrack(video_track)
483
+ pc.addTrack(audio_track)
484
+
485
+ # Criar sessao
486
+ session = AvatarSession(pc, video_track, audio_track)
487
+ sessions[pc_id] = session
488
+
489
+ @pc.on("iceconnectionstatechange")
490
+ async def on_ice_state():
491
+ print(f"[ICE] Estado: {pc.iceConnectionState}")
492
+
493
+ @pc.on("icegatheringstatechange")
494
+ async def on_ice_gathering():
495
+ print(f"[ICE] Gathering: {pc.iceGatheringState}")
496
+
497
+ @pc.on("connectionstatechange")
498
+ async def on_connectionstatechange():
499
+ print(f"[WebRTC] Estado: {pc.connectionState}")
500
+ if pc.connectionState == "failed" or pc.connectionState == "closed":
501
+ await pc.close()
502
+ pcs.discard(pc)
503
+ if pc_id in sessions:
504
+ await sessions[pc_id].close()
505
+ del sessions[pc_id]
506
+
507
+ # Processar offer e criar answer
508
+ await pc.setRemoteDescription(offer)
509
+ answer = await pc.createAnswer()
510
+ await pc.setLocalDescription(answer)
511
+
512
+ return web.json_response({
513
+ "sdp": pc.localDescription.sdp,
514
+ "type": pc.localDescription.type,
515
+ "session_id": pc_id
516
+ })
517
+
518
+
519
+ @routes.post("/generate")
520
+ async def generate(request):
521
+ """Gera fala com lip-sync."""
522
+ params = await request.json()
523
+ session_id = params.get("session_id")
524
+ text = params.get("text", "").strip()
525
+ voice = params.get("voice", "tara")
526
+
527
+ if not session_id or session_id not in sessions:
528
+ return web.json_response({"error": "Sessao invalida"}, status=400)
529
+
530
+ if not text:
531
+ return web.json_response({"error": "Texto obrigatorio"}, status=400)
532
+
533
+ session = sessions[session_id]
534
+ asyncio.create_task(session.generate(text, voice))
535
+
536
+ return web.json_response({"status": "generating"})
537
 
538
 
539
  @routes.get("/")
 
541
  return web.FileResponse(os.path.join(os.path.dirname(__file__), "index.html"))
542
 
543
 
 
 
 
 
 
544
  @routes.get("/{filename}")
545
  async def static_file(request):
546
  filename = request.match_info["filename"]
 
552
 
553
  @routes.get("/health")
554
  async def health(request):
555
+ return web.json_response({
556
+ "status": "ok",
557
+ "mode": "webrtc",
558
+ "connections": len(pcs)
559
+ })
560
+
561
+
562
+ async def on_shutdown(app):
563
+ """Fecha todas as conexoes ao desligar."""
564
+ coros = [pc.close() for pc in pcs]
565
+ await asyncio.gather(*coros)
566
+ pcs.clear()
567
 
568
 
569
  app = web.Application()
570
  app.add_routes(routes)
571
+ app.on_shutdown.append(on_shutdown)
572
 
573
 
574
  if __name__ == "__main__":
575
  print("=" * 50)
576
+ print("Interface Server - WebRTC VP9 Streaming")
577
  print("=" * 50)
578
  print(f"Porta: {PORT}")
579
+ print(f"Idle Video: {IDLE_VIDEO}")
580
  print(f"Wav2Lip: {WAV2LIP_WS}")
 
 
581
  print("=" * 50)
582
+ print("Endpoints:")
583
+ print(" POST /offer - WebRTC signaling")
584
+ print(" POST /generate - Gerar fala")
585
+ print("=" * 50)
586
+
587
+ # Pre-carregar idle frames
588
+ print("Carregando idle frames...")
589
+ load_idle_frames()
590
+ print("=" * 50)
591
+
592
  web.run_app(app, host="0.0.0.0", port=PORT)
interface/server_optimized.py CHANGED
@@ -48,6 +48,8 @@ class OptimizedSession:
48
  self.total_frames = 0
49
  self.total_bytes = 0
50
  self.end_video_time_ms = None # Tempo do vídeo onde parou (para sync)
 
 
51
 
52
  async def send_json(self, msg_type: str, **kwargs):
53
  """Envia mensagem JSON (para status/controle)."""
@@ -162,7 +164,7 @@ class OptimizedSession:
162
 
163
  return total_chunks > 0
164
 
165
- async def run(self, text: str, voice: str):
166
  """Streaming otimizado - áudio e frames em paralelo, enviados conforme chegam."""
167
  self.is_running = True
168
  self.start_time = time.time()
@@ -186,17 +188,19 @@ class OptimizedSession:
186
 
187
  await self.send_json("status", message="Gerando...")
188
 
189
- # Enviar texto para Wav2Lip
190
  await wav2lip_ws.send_json({
191
- "action": "speak",
192
  "text": text,
193
  "voice": voice,
194
- "parallel": True
 
195
  })
196
 
197
  print(f"[Optimized] Texto: '{text[:50]}...'")
198
 
199
  first_frame_time = None
 
200
 
201
  # Receber frames do Wav2Lip
202
  async for msg in wav2lip_ws:
@@ -217,8 +221,10 @@ class OptimizedSession:
217
  if first_frame_time is None:
218
  first_frame_time = time.time() - self.start_time
219
  latency_ms = int(first_frame_time * 1000)
220
- print(f"[Optimized] Primeiro frame: {latency_ms}ms")
221
- await self.send_json("first_frame", latency_ms=latency_ms)
 
 
222
 
223
  elif msg_type == "full_audio":
224
  print(f"[Optimized] Ignorando audio Wav2Lip (usando Orpheus streaming)")
@@ -234,10 +240,14 @@ class OptimizedSession:
234
  break
235
 
236
  elif msg_type == "done":
237
- # Capturar end_video_time_ms para sincronização
238
  self.end_video_time_ms = data.get("end_video_time_ms")
 
 
239
  end_frame_idx = data.get("end_frame_idx")
240
- print(f"[Optimized] Wav2Lip done: {self.total_frames} frames, end_video_time_ms={self.end_video_time_ms}, end_frame_idx={end_frame_idx}")
 
 
241
  break
242
 
243
  elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
@@ -272,6 +282,8 @@ class OptimizedSession:
272
  total_frames=self.total_frames,
273
  elapsed_ms=int(elapsed * 1000),
274
  bytes_sent=self.total_bytes,
 
 
275
  end_video_time_ms=self.end_video_time_ms
276
  )
277
 
@@ -297,18 +309,20 @@ async def websocket_handler(request):
297
  if action == "generate":
298
  text = data.get("text", "").strip()
299
  voice = data.get("voice", "tara")
 
 
300
 
301
  if not text:
302
  await ws.send_json({"type": "error", "message": "Text required"})
303
  continue
304
 
305
- print(f"Gerando: '{text[:50]}...'")
306
 
307
  if session:
308
  session.stop()
309
 
310
  session = OptimizedSession(ws)
311
- await session.run(text, voice)
312
 
313
  elif action == "stop":
314
  if session:
 
48
  self.total_frames = 0
49
  self.total_bytes = 0
50
  self.end_video_time_ms = None # Tempo do vídeo onde parou (para sync)
51
+ self.start_frame_idx = None # Frame inicial usado pelo Wav2Lip
52
+ self.end_frame_idx = None # Frame final usado pelo Wav2Lip
53
 
54
  async def send_json(self, msg_type: str, **kwargs):
55
  """Envia mensagem JSON (para status/controle)."""
 
164
 
165
  return total_chunks > 0
166
 
167
+ async def run(self, text: str, voice: str, jpeg_quality: int = 95, idle_video_time_ms: int = 0):
168
  """Streaming otimizado - áudio e frames em paralelo, enviados conforme chegam."""
169
  self.is_running = True
170
  self.start_time = time.time()
 
188
 
189
  await self.send_json("status", message="Gerando...")
190
 
191
+ # Enviar texto para Wav2Lip com action "generate" para receber end_video_time_ms
192
  await wav2lip_ws.send_json({
193
+ "action": "generate",
194
  "text": text,
195
  "voice": voice,
196
+ "idle_video_time_ms": idle_video_time_ms, # Tempo inicial do vídeo idle
197
+ "jpeg_quality": jpeg_quality # Qualidade do JPEG (50-100)
198
  })
199
 
200
  print(f"[Optimized] Texto: '{text[:50]}...'")
201
 
202
  first_frame_time = None
203
+ start_frame_idx = None # Frame inicial usado pelo Wav2Lip
204
 
205
  # Receber frames do Wav2Lip
206
  async for msg in wav2lip_ws:
 
221
  if first_frame_time is None:
222
  first_frame_time = time.time() - self.start_time
223
  latency_ms = int(first_frame_time * 1000)
224
+ # Capturar start_frame_idx do primeiro frame
225
+ start_frame_idx = data.get("source_frame_idx", None)
226
+ print(f"[Optimized] Primeiro frame: {latency_ms}ms, source_frame={start_frame_idx}")
227
+ await self.send_json("first_frame", latency_ms=latency_ms, start_frame_idx=start_frame_idx)
228
 
229
  elif msg_type == "full_audio":
230
  print(f"[Optimized] Ignorando audio Wav2Lip (usando Orpheus streaming)")
 
240
  break
241
 
242
  elif msg_type == "done":
243
+ # Capturar índices de frame para sincronização
244
  self.end_video_time_ms = data.get("end_video_time_ms")
245
+ if start_frame_idx is None:
246
+ start_frame_idx = data.get("start_frame_idx")
247
  end_frame_idx = data.get("end_frame_idx")
248
+ self.start_frame_idx = start_frame_idx
249
+ self.end_frame_idx = end_frame_idx
250
+ print(f"[Optimized] Wav2Lip done: {self.total_frames} frames, start_frame={start_frame_idx}, end_frame={end_frame_idx}, end_time_ms={self.end_video_time_ms}")
251
  break
252
 
253
  elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
 
282
  total_frames=self.total_frames,
283
  elapsed_ms=int(elapsed * 1000),
284
  bytes_sent=self.total_bytes,
285
+ start_frame_idx=self.start_frame_idx,
286
+ end_frame_idx=self.end_frame_idx,
287
  end_video_time_ms=self.end_video_time_ms
288
  )
289
 
 
309
  if action == "generate":
310
  text = data.get("text", "").strip()
311
  voice = data.get("voice", "tara")
312
+ jpeg_quality = data.get("jpeg_quality", 95) # Qualidade JPEG (50-100)
313
+ idle_video_time_ms = data.get("idle_video_time_ms", 0) # Tempo do vídeo idle
314
 
315
  if not text:
316
  await ws.send_json({"type": "error", "message": "Text required"})
317
  continue
318
 
319
+ print(f"Gerando: '{text[:50]}...' (quality={jpeg_quality}, idle_time={idle_video_time_ms}ms)")
320
 
321
  if session:
322
  session.stop()
323
 
324
  session = OptimizedSession(ws)
325
+ await session.run(text, voice, jpeg_quality, idle_video_time_ms)
326
 
327
  elif action == "stop":
328
  if session:
interface/server_streaming.py CHANGED
@@ -24,11 +24,22 @@ routes = web.RouteTableDef()
24
  # Cache de frames idle
25
  idle_frames = []
26
  idle_frame_count = 0
 
 
 
 
 
 
 
 
 
 
 
27
 
28
 
29
  def load_idle_frames():
30
- """Carrega frames do idle.mp4"""
31
- global idle_frames, idle_frame_count
32
 
33
  if idle_frames:
34
  return
@@ -40,16 +51,21 @@ def load_idle_frames():
40
  print(f"Carregando idle frames de {IDLE_VIDEO}...")
41
  cap = cv2.VideoCapture(IDLE_VIDEO)
42
 
 
 
 
 
 
 
43
  while True:
44
  ret, frame = cap.read()
45
  if not ret:
46
  break
47
- # Manter em BGR para processamento, converter para JPEG depois
48
  idle_frames.append(frame)
49
 
50
  cap.release()
51
  idle_frame_count = len(idle_frames)
52
- print(f"Carregados {idle_frame_count} frames idle")
53
 
54
 
55
  def frame_to_jpeg_base64(frame, quality=85):
@@ -66,6 +82,403 @@ def jpeg_base64_to_frame(b64_data):
66
  return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
67
 
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  def blend_frames(frame1, frame2, alpha):
70
  """Blend entre dois frames. alpha=0 -> frame1, alpha=1 -> frame2"""
71
  # Garantir que ambos frames tem o mesmo tamanho
@@ -132,6 +545,13 @@ async def websocket_handler(request):
132
  audio_duration = 0
133
  end_video_time_ms = 0
134
 
 
 
 
 
 
 
 
135
  async for w2l_msg in wav2lip_ws:
136
  if w2l_msg.type == aiohttp.WSMsgType.TEXT:
137
  w2l_data = json.loads(w2l_msg.data)
@@ -144,6 +564,20 @@ async def websocket_handler(request):
144
  frame_b64 = w2l_data.get("frame", "")
145
  if frame_b64:
146
  frame = jpeg_base64_to_frame(frame_b64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  speaking_frames.append(frame)
148
 
149
  elif msg_type == "full_audio":
@@ -166,17 +600,45 @@ async def websocket_handler(request):
166
 
167
  # Enviar frames SEM crossfade - transicao e feita no cliente
168
  if speaking_frames:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
169
  # Atualizar posicao do idle para continuidade apos fala
170
  if idle_frames:
171
- idle_position = (idle_position + len(speaking_frames)) % idle_frame_count
172
 
173
  # Enviar stream_start
174
  ttfb = int((time.time() - start_time) * 1000)
175
  await ws.send_json({"type": "stream_start", "ttfb_ms": ttfb})
176
 
177
  # Enviar apenas os frames de fala (sem crossfade)
 
178
  for idx, frame in enumerate(speaking_frames):
179
- frame_b64 = frame_to_jpeg_base64(frame)
180
  await ws.send_json({
181
  "type": "frame",
182
  "frame": frame_b64,
@@ -200,7 +662,7 @@ async def websocket_handler(request):
200
  "end_video_time_ms": end_video_time_ms
201
  })
202
 
203
- print(f"Enviados {len(speaking_frames)} frames de fala (sem crossfade)")
204
 
205
  except Exception as e:
206
  print(f"Erro: {e}")
@@ -286,14 +748,16 @@ app.add_routes(routes)
286
 
287
  if __name__ == "__main__":
288
  print("=" * 50)
289
- print("Streaming Server com Crossfade - Porta", PORT)
290
  print("Wav2Lip:", WAV2LIP_WS)
291
  print("Idle Video:", IDLE_VIDEO)
292
- print("Crossfade: DESABILITADO (transicao no cliente)")
293
  print("=" * 50)
294
 
295
  # Carregar idle frames
296
  load_idle_frames()
297
 
 
 
 
298
  print("=" * 50)
299
  web.run_app(app, host="0.0.0.0", port=PORT)
 
24
  # Cache de frames idle
25
  idle_frames = []
26
  idle_frame_count = 0
27
+ idle_resolution = (1920, 1080) # Resolucao do idle video (width, height)
28
+
29
+ # Regiao da boca/queixo (em ratio do frame)
30
+ # Regiao mais focada para evitar "pulos" na transicao
31
+ # Apenas boca e queixo, sem incluir muito do rosto
32
+ MOUTH_REGION = {
33
+ 'top': 0.50, # 50% do topo (comeca abaixo do nariz)
34
+ 'bottom': 0.80, # ate 80% (apenas queixo)
35
+ 'left': 0.32, # 32% da esquerda
36
+ 'right': 0.68 # ate 68% (mais estreito)
37
+ }
38
 
39
 
40
  def load_idle_frames():
41
+ """Carrega frames do idle.mp4 e obtem resolucao"""
42
+ global idle_frames, idle_frame_count, idle_resolution
43
 
44
  if idle_frames:
45
  return
 
51
  print(f"Carregando idle frames de {IDLE_VIDEO}...")
52
  cap = cv2.VideoCapture(IDLE_VIDEO)
53
 
54
+ # Obter resolucao do video
55
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
56
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
57
+ idle_resolution = (width, height)
58
+ print(f"Resolucao idle: {width}x{height}")
59
+
60
  while True:
61
  ret, frame = cap.read()
62
  if not ret:
63
  break
 
64
  idle_frames.append(frame)
65
 
66
  cap.release()
67
  idle_frame_count = len(idle_frames)
68
+ print(f"Carregados {idle_frame_count} frames idle em full resolution")
69
 
70
 
71
  def frame_to_jpeg_base64(frame, quality=85):
 
82
  return cv2.imdecode(nparr, cv2.IMREAD_COLOR)
83
 
84
 
85
+ def upscale_frame(frame, target_size):
86
+ """
87
+ Upscale frame para a resolucao alvo usando LANCZOS4 (alta qualidade).
88
+ target_size: (width, height)
89
+ """
90
+ if frame is None:
91
+ return frame
92
+
93
+ current_h, current_w = frame.shape[:2]
94
+ target_w, target_h = target_size
95
+
96
+ # Se ja esta na resolucao correta, retornar
97
+ if current_w == target_w and current_h == target_h:
98
+ return frame
99
+
100
+ # Upscale usando LANCZOS4 (melhor qualidade para upscaling)
101
+ upscaled = cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4)
102
+ return upscaled
103
+
104
+
105
+ def match_histogram(source, reference):
106
+ """
107
+ Ajusta o histograma da source para corresponder ao da reference.
108
+ Isso corrige diferencas de brilho/cor entre Wav2Lip e idle frames.
109
+ Usa o espaco de cor LAB para melhor correspondencia perceptual.
110
+ """
111
+ # Converter para LAB (melhor para correspondencia de cor)
112
+ source_lab = cv2.cvtColor(source, cv2.COLOR_BGR2LAB).astype(np.float32)
113
+ reference_lab = cv2.cvtColor(reference, cv2.COLOR_BGR2LAB).astype(np.float32)
114
+
115
+ # Para cada canal, ajustar media e desvio padrao
116
+ for i in range(3):
117
+ src_mean, src_std = source_lab[:, :, i].mean(), source_lab[:, :, i].std()
118
+ ref_mean, ref_std = reference_lab[:, :, i].mean(), reference_lab[:, :, i].std()
119
+
120
+ # Evitar divisao por zero
121
+ if src_std < 1e-6:
122
+ src_std = 1e-6
123
+
124
+ # Normalizar e reescalar
125
+ source_lab[:, :, i] = (source_lab[:, :, i] - src_mean) * (ref_std / src_std) + ref_mean
126
+
127
+ # Clipar valores validos e converter de volta
128
+ source_lab = np.clip(source_lab, 0, 255).astype(np.uint8)
129
+ result = cv2.cvtColor(source_lab, cv2.COLOR_LAB2BGR)
130
+
131
+ return result
132
+
133
+
134
+ def extract_mouth_region(frame, region=MOUTH_REGION):
135
+ """
136
+ Extrai apenas a regiao da boca/queixo do frame.
137
+ Retorna (regiao_cortada, coordenadas) para posterior blending.
138
+ """
139
+ h, w = frame.shape[:2]
140
+
141
+ y1 = int(h * region['top'])
142
+ y2 = int(h * region['bottom'])
143
+ x1 = int(w * region['left'])
144
+ x2 = int(w * region['right'])
145
+
146
+ mouth_crop = frame[y1:y2, x1:x2].copy()
147
+ return mouth_crop, (x1, y1, x2, y2)
148
+
149
+
150
+ def create_feathered_mask(shape, feather_pixels=15):
151
+ """
152
+ Cria mascara com bordas suavizadas (feathered) para blending seamless.
153
+ Usa gradiente suave (ease-in-out) para transicao mais natural.
154
+ """
155
+ h, w = shape[:2]
156
+ mask = np.ones((h, w), dtype=np.float32)
157
+
158
+ # Criar gradiente nas bordas usando curva suave (ease-in-out)
159
+ for i in range(feather_pixels):
160
+ # Curva suave: smoothstep para transicao mais natural
161
+ t = i / feather_pixels
162
+ alpha = t * t * (3 - 2 * t) # smoothstep
163
+
164
+ # Top
165
+ mask[i, :] = np.minimum(mask[i, :], alpha)
166
+ # Bottom
167
+ mask[h - 1 - i, :] = np.minimum(mask[h - 1 - i, :], alpha)
168
+ # Left
169
+ mask[:, i] = np.minimum(mask[:, i], alpha)
170
+ # Right
171
+ mask[:, w - 1 - i] = np.minimum(mask[:, w - 1 - i], alpha)
172
+
173
+ return mask
174
+
175
+
176
+ def blend_mouth_region_only(wav2lip_frame, idle_frame):
177
+ """
178
+ Nova estrategia: Manter idle em full resolution, substituir APENAS a boca.
179
+
180
+ 1. Extrai regiao da boca do frame Wav2Lip (853x480)
181
+ 2. Upscala APENAS essa regiao para a escala do idle (1920x1080)
182
+ 3. Aplica Poisson Blending apenas na regiao da boca
183
+ 4. Retorna o frame idle com apenas a boca substituida
184
+
185
+ Isso preserva toda a qualidade do idle (cabelo, fundo, roupa) e
186
+ so substitui a pequena regiao da boca.
187
+ """
188
+ if wav2lip_frame is None or idle_frame is None:
189
+ return wav2lip_frame if wav2lip_frame is not None else idle_frame
190
+
191
+ # Dimensoes
192
+ idle_h, idle_w = idle_frame.shape[:2]
193
+ w2l_h, w2l_w = wav2lip_frame.shape[:2]
194
+
195
+ # Calcular escala entre frames
196
+ scale_x = idle_w / w2l_w
197
+ scale_y = idle_h / w2l_h
198
+
199
+ # 1. Extrair regiao da boca do Wav2Lip
200
+ mouth_crop, (x1_w2l, y1_w2l, x2_w2l, y2_w2l) = extract_mouth_region(wav2lip_frame)
201
+
202
+ # 2. Calcular coordenadas equivalentes no idle (full res)
203
+ x1_idle = int(x1_w2l * scale_x)
204
+ y1_idle = int(y1_w2l * scale_y)
205
+ x2_idle = int(x2_w2l * scale_x)
206
+ y2_idle = int(y2_w2l * scale_y)
207
+
208
+ # Dimensao da regiao no idle
209
+ region_w = x2_idle - x1_idle
210
+ region_h = y2_idle - y1_idle
211
+
212
+ # 3. Upscale apenas a regiao da boca para a resolucao do idle
213
+ mouth_upscaled = cv2.resize(mouth_crop, (region_w, region_h), interpolation=cv2.INTER_LANCZOS4)
214
+
215
+ # 3.5 Histogram matching: ajustar cor/brilho do mouth para corresponder ao idle
216
+ idle_region = idle_frame[y1_idle:y2_idle, x1_idle:x2_idle]
217
+ mouth_upscaled = match_histogram(mouth_upscaled, idle_region)
218
+
219
+ # 4. Criar mascara com bordas suavizadas
220
+ # Usar 25% da menor dimensao para feathering bem suave
221
+ feather = max(30, min(region_w, region_h) // 4) # ~25% da menor dimensao
222
+ mask = create_feathered_mask((region_h, region_w), feather_pixels=feather)
223
+ mask_3ch = np.dstack([mask, mask, mask])
224
+
225
+ # 5. Fazer copia do idle e aplicar blending na regiao
226
+ result = idle_frame.copy()
227
+
228
+ # Regiao do idle onde vai o mouth
229
+ idle_region = result[y1_idle:y2_idle, x1_idle:x2_idle]
230
+
231
+ # Blending com mascara feathered
232
+ blended_region = (mouth_upscaled * mask_3ch + idle_region * (1 - mask_3ch)).astype(np.uint8)
233
+
234
+ # Substituir regiao
235
+ result[y1_idle:y2_idle, x1_idle:x2_idle] = blended_region
236
+
237
+ return result
238
+
239
+
240
+ def blend_with_poisson(wav2lip_frame, idle_frame):
241
+ """
242
+ Estrategia alternativa: Poisson Blending apenas na regiao da boca.
243
+ Mais lento mas com transicao mais suave nos bordos.
244
+ """
245
+ if wav2lip_frame is None or idle_frame is None:
246
+ return wav2lip_frame if wav2lip_frame is not None else idle_frame
247
+
248
+ idle_h, idle_w = idle_frame.shape[:2]
249
+ w2l_h, w2l_w = wav2lip_frame.shape[:2]
250
+
251
+ scale_x = idle_w / w2l_w
252
+ scale_y = idle_h / w2l_h
253
+
254
+ # Extrair e upscalar boca
255
+ mouth_crop, (x1_w2l, y1_w2l, x2_w2l, y2_w2l) = extract_mouth_region(wav2lip_frame)
256
+
257
+ x1_idle = int(x1_w2l * scale_x)
258
+ y1_idle = int(y1_w2l * scale_y)
259
+ x2_idle = int(x2_w2l * scale_x)
260
+ y2_idle = int(y2_w2l * scale_y)
261
+
262
+ region_w = x2_idle - x1_idle
263
+ region_h = y2_idle - y1_idle
264
+
265
+ mouth_upscaled = cv2.resize(mouth_crop, (region_w, region_h), interpolation=cv2.INTER_LANCZOS4)
266
+
267
+ # Criar imagem source do tamanho do idle (preta com boca no lugar certo)
268
+ source = np.zeros_like(idle_frame)
269
+ source[y1_idle:y2_idle, x1_idle:x2_idle] = mouth_upscaled
270
+
271
+ # Criar mascara eliptica para a regiao
272
+ mask = np.zeros((idle_h, idle_w), dtype=np.uint8)
273
+ center_x = (x1_idle + x2_idle) // 2
274
+ center_y = (y1_idle + y2_idle) // 2
275
+ axes_x = region_w // 2 - 10 # Um pouco menor para evitar bordas
276
+ axes_y = region_h // 2 - 10
277
+ cv2.ellipse(mask, (center_x, center_y), (axes_x, axes_y), 0, 0, 360, 255, -1)
278
+
279
+ try:
280
+ result = cv2.seamlessClone(
281
+ source,
282
+ idle_frame,
283
+ mask,
284
+ (center_x, center_y),
285
+ cv2.NORMAL_CLONE
286
+ )
287
+ return result
288
+ except Exception as e:
289
+ print(f"[Poisson] Erro: {e}, usando feathered blend")
290
+ return blend_mouth_region_only(wav2lip_frame, idle_frame)
291
+
292
+
293
+ def calculate_frame_difference(frame1, frame2):
294
+ """
295
+ Calcula a diferenca entre dois frames.
296
+ Retorna um valor de 0-100 indicando quanta diferenca ha.
297
+ """
298
+ if frame1 is None or frame2 is None:
299
+ return 0
300
+
301
+ # Converter para grayscale
302
+ gray1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY)
303
+ gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY)
304
+
305
+ # Calcular diferenca absoluta
306
+ diff = cv2.absdiff(gray1, gray2)
307
+
308
+ # Valor medio da diferenca (0-255)
309
+ mean_diff = np.mean(diff)
310
+
311
+ # Normalizar para 0-100
312
+ return (mean_diff / 255.0) * 100
313
+
314
+
315
+ def calculate_sharpness(frame):
316
+ """
317
+ Calcula a nitidez de um frame usando variância do Laplaciano.
318
+ Quanto maior o valor, mais nítido o frame.
319
+ """
320
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if len(frame.shape) == 3 else frame
321
+ laplacian = cv2.Laplacian(gray, cv2.CV_64F)
322
+ return laplacian.var()
323
+
324
+
325
+ def find_best_matching_idle_frame(target_frame, idle_frames, sample_step=5, sharpness_weight=0.3):
326
+ """
327
+ Encontra o frame do idle mais similar ao target_frame.
328
+ Considera tanto similaridade quanto nitidez para evitar frames desfocados.
329
+
330
+ Args:
331
+ target_frame: Frame para comparar (último frame da fala)
332
+ idle_frames: Lista de frames idle
333
+ sample_step: Passo de amostragem (5 = compara 1 a cada 5 frames)
334
+ sharpness_weight: Peso da nitidez no score (0-1)
335
+
336
+ Returns:
337
+ Índice do frame idle mais similar e nítido
338
+ """
339
+ if not idle_frames or target_frame is None:
340
+ return 0, 0
341
+
342
+ # Converter target para grayscale uma vez
343
+ target_gray = cv2.cvtColor(target_frame, cv2.COLOR_BGR2GRAY)
344
+
345
+ # Primeira fase: encontrar os N melhores candidatos por similaridade
346
+ candidates = []
347
+
348
+ for i in range(0, len(idle_frames), sample_step):
349
+ idle_gray = cv2.cvtColor(idle_frames[i], cv2.COLOR_BGR2GRAY)
350
+ diff = np.mean(cv2.absdiff(target_gray, idle_gray))
351
+ candidates.append((i, diff))
352
+
353
+ # Ordenar por diferença (menor = mais similar)
354
+ candidates.sort(key=lambda x: x[1])
355
+
356
+ # Pegar os top 20 candidatos mais similares
357
+ top_candidates = candidates[:20]
358
+
359
+ # Segunda fase: refinar busca na vizinhança dos top candidatos
360
+ refined_candidates = []
361
+
362
+ for idx, _ in top_candidates:
363
+ start = max(0, idx - sample_step)
364
+ end = min(len(idle_frames), idx + sample_step + 1)
365
+
366
+ for i in range(start, end):
367
+ idle_frame = idle_frames[i]
368
+ idle_gray = cv2.cvtColor(idle_frame, cv2.COLOR_BGR2GRAY)
369
+
370
+ # Calcular diferença
371
+ diff = np.mean(cv2.absdiff(target_gray, idle_gray))
372
+
373
+ # Calcular nitidez
374
+ sharpness = calculate_sharpness(idle_frame)
375
+
376
+ refined_candidates.append((i, diff, sharpness))
377
+
378
+ if not refined_candidates:
379
+ return 0, 0
380
+
381
+ # Normalizar valores para scoring
382
+ diffs = [c[1] for c in refined_candidates]
383
+ sharpnesses = [c[2] for c in refined_candidates]
384
+
385
+ min_diff, max_diff = min(diffs), max(diffs)
386
+ min_sharp, max_sharp = min(sharpnesses), max(sharpnesses)
387
+
388
+ # Evitar divisão por zero
389
+ diff_range = max_diff - min_diff if max_diff > min_diff else 1
390
+ sharp_range = max_sharp - min_sharp if max_sharp > min_sharp else 1
391
+
392
+ # Calcular score combinado (menor = melhor)
393
+ # diff_score: 0 = mais similar, 1 = menos similar
394
+ # sharp_score: 0 = mais nítido, 1 = menos nítido (invertido)
395
+ best_idx = 0
396
+ best_score = float('inf')
397
+ best_diff = 0
398
+
399
+ for i, diff, sharpness in refined_candidates:
400
+ diff_score = (diff - min_diff) / diff_range
401
+ sharp_score = 1 - (sharpness - min_sharp) / sharp_range # Invertido: maior nitidez = menor score
402
+
403
+ # Score combinado
404
+ combined_score = (1 - sharpness_weight) * diff_score + sharpness_weight * sharp_score
405
+
406
+ if combined_score < best_score:
407
+ best_score = combined_score
408
+ best_idx = i
409
+ best_diff = diff
410
+
411
+ return best_idx, best_diff
412
+
413
+
414
+ def trim_high_motion_frames(frames, threshold_multiplier=1.0, max_trim=20):
415
+ """
416
+ Remove frames do final que tem movimento muito alto (saltos).
417
+ Isso elimina os frames problemáticos que causam "travamento".
418
+
419
+ Versão mais agressiva: usa threshold menor e remove mais frames.
420
+
421
+ Args:
422
+ frames: Lista de frames
423
+ threshold_multiplier: Multiplicador do threshold (media + multiplier * std)
424
+ max_trim: Maximo de frames a remover
425
+
426
+ Returns:
427
+ Lista de frames com os problematicos removidos
428
+ """
429
+ if len(frames) < 20:
430
+ return frames
431
+
432
+ # Calcular diferenças entre frames consecutivos (últimos 20)
433
+ last_n = min(20, len(frames) - 1)
434
+ differences = []
435
+ for i in range(len(frames) - last_n, len(frames)):
436
+ if i > 0:
437
+ diff = calculate_frame_difference(frames[i-1], frames[i])
438
+ differences.append((i, diff))
439
+
440
+ if not differences:
441
+ return frames
442
+
443
+ # Calcular média e desvio padrão
444
+ diffs = [d[1] for d in differences]
445
+ mean_diff = np.mean(diffs)
446
+ std_diff = np.std(diffs)
447
+
448
+ # Threshold mais agressivo: média + 1.0*std (antes era 1.5)
449
+ threshold = mean_diff + threshold_multiplier * std_diff
450
+
451
+ # Threshold mínimo absoluto para evitar frames com muito movimento
452
+ min_threshold = 0.7 # Frames com diff > 0.7 são sempre problemáticos
453
+ if threshold > min_threshold:
454
+ threshold = min_threshold
455
+
456
+ # Encontrar onde começam os frames problemáticos (do fim para o início)
457
+ trim_from = len(frames)
458
+ frames_removed = 0
459
+
460
+ # Abordagem mais agressiva: remove todos os frames problemáticos do final
461
+ for i in range(len(differences) - 1, -1, -1):
462
+ idx, diff = differences[i]
463
+ if diff > threshold:
464
+ trim_from = idx
465
+ frames_removed += 1
466
+ if frames_removed >= max_trim:
467
+ break
468
+ else:
469
+ # Para no primeiro frame bom encontrado
470
+ break
471
+
472
+ # Calcular quantos frames remover
473
+ frames_to_trim = len(frames) - trim_from
474
+
475
+ if frames_to_trim > 0 and frames_to_trim <= max_trim:
476
+ print(f"[Trim] Removendo {frames_to_trim} frames problemáticos (threshold: {threshold:.2f}, mean: {mean_diff:.2f})")
477
+ return frames[:trim_from]
478
+
479
+ return frames
480
+
481
+
482
  def blend_frames(frame1, frame2, alpha):
483
  """Blend entre dois frames. alpha=0 -> frame1, alpha=1 -> frame2"""
484
  # Garantir que ambos frames tem o mesmo tamanho
 
545
  audio_duration = 0
546
  end_video_time_ms = 0
547
 
548
+ # Calcular posicao inicial no idle baseado no tempo
549
+ # idle_video_time_ms em ms, video @ 25fps = 40ms/frame
550
+ fps = 25
551
+ frame_duration_ms = 1000 / fps
552
+ start_idle_idx = int(idle_video_time_ms / frame_duration_ms) % idle_frame_count if idle_frame_count > 0 else 0
553
+ current_idle_idx = start_idle_idx
554
+
555
  async for w2l_msg in wav2lip_ws:
556
  if w2l_msg.type == aiohttp.WSMsgType.TEXT:
557
  w2l_data = json.loads(w2l_msg.data)
 
564
  frame_b64 = w2l_data.get("frame", "")
565
  if frame_b64:
566
  frame = jpeg_base64_to_frame(frame_b64)
567
+
568
+ # Pegar frame idle full-res correspondente para histogram matching
569
+ idle_ref = None
570
+ if idle_frames and idle_frame_count > 0:
571
+ idle_ref = idle_frames[current_idle_idx]
572
+ current_idle_idx = (current_idle_idx + 1) % idle_frame_count
573
+
574
+ # Upscale frame inteiro do Wav2Lip
575
+ frame = upscale_frame(frame, idle_resolution)
576
+
577
+ # Histogram matching para consistencia de cor
578
+ if idle_ref is not None:
579
+ frame = match_histogram(frame, idle_ref)
580
+
581
  speaking_frames.append(frame)
582
 
583
  elif msg_type == "full_audio":
 
600
 
601
  # Enviar frames SEM crossfade - transicao e feita no cliente
602
  if speaking_frames:
603
+ # 1. Primeiro, remover frames problemáticos do final (alto movimento)
604
+ original_count = len(speaking_frames)
605
+ speaking_frames = trim_high_motion_frames(speaking_frames)
606
+ if len(speaking_frames) < original_count:
607
+ print(f"[Motion Trim] {original_count} -> {len(speaking_frames)} frames")
608
+
609
+ # 2. Depois, trim para match audio duration (se ainda houver excesso)
610
+ fps = 25
611
+ if audio_duration > 0:
612
+ expected_frames = int(audio_duration / 1000 * fps)
613
+ if len(speaking_frames) > expected_frames:
614
+ trimmed = len(speaking_frames) - expected_frames
615
+ print(f"[Duration Trim] {trimmed} extra frames ({len(speaking_frames)} -> {expected_frames})")
616
+ speaking_frames = speaking_frames[:expected_frames]
617
+
618
+ # 3. Encontrar o frame idle mais similar ao último frame de fala
619
+ # Isso minimiza o "salto" visual na transição speak->idle
620
+ best_idle_idx = 0
621
+ if idle_frames and speaking_frames:
622
+ last_speak_frame = speaking_frames[-1]
623
+ best_idle_idx, best_diff = find_best_matching_idle_frame(
624
+ last_speak_frame, idle_frames, sample_step=10
625
+ )
626
+ # Converter índice para tempo em ms (25fps = 40ms/frame)
627
+ end_video_time_ms = int(best_idle_idx * 40)
628
+ print(f"[Best Match] Idle frame {best_idle_idx} (diff: {best_diff:.2f}) -> {end_video_time_ms}ms")
629
+
630
  # Atualizar posicao do idle para continuidade apos fala
631
  if idle_frames:
632
+ idle_position = best_idle_idx
633
 
634
  # Enviar stream_start
635
  ttfb = int((time.time() - start_time) * 1000)
636
  await ws.send_json({"type": "stream_start", "ttfb_ms": ttfb})
637
 
638
  # Enviar apenas os frames de fala (sem crossfade)
639
+ # Usar qualidade JPEG alta (95) para minimizar artefatos
640
  for idx, frame in enumerate(speaking_frames):
641
+ frame_b64 = frame_to_jpeg_base64(frame, quality=95)
642
  await ws.send_json({
643
  "type": "frame",
644
  "frame": frame_b64,
 
662
  "end_video_time_ms": end_video_time_ms
663
  })
664
 
665
+ print(f"Enviados {len(speaking_frames)} frames (Poisson Blending)")
666
 
667
  except Exception as e:
668
  print(f"Erro: {e}")
 
748
 
749
  if __name__ == "__main__":
750
  print("=" * 50)
751
+ print("Streaming Server - Porta", PORT)
752
  print("Wav2Lip:", WAV2LIP_WS)
753
  print("Idle Video:", IDLE_VIDEO)
 
754
  print("=" * 50)
755
 
756
  # Carregar idle frames
757
  load_idle_frames()
758
 
759
+ print(f"Upscaling: ENABLED (target {idle_resolution[0]}x{idle_resolution[1]})")
760
+ print("Interpolacao: LANCZOS4 (alta qualidade)")
761
+ print("Color: HISTOGRAM MATCHING (LAB color space)")
762
  print("=" * 50)
763
  web.run_app(app, host="0.0.0.0", port=PORT)
interface/test_webrtc_client.py ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cliente WebRTC automatizado para testar conexão com servidor
4
+ """
5
+
6
+ import asyncio
7
+ import aiohttp
8
+ import sys
9
+ from aiortc import RTCPeerConnection, RTCConfiguration, RTCIceServer, RTCSessionDescription
10
+ from aiortc.contrib.media import MediaRecorder
11
+ import time
12
+
13
+ # Configuração
14
+ SERVER_URL = "http://62.107.25.198:47898"
15
+
16
+ # Cores para terminal
17
+ GREEN = '\033[92m'
18
+ RED = '\033[91m'
19
+ YELLOW = '\033[93m'
20
+ BLUE = '\033[94m'
21
+ RESET = '\033[0m'
22
+
23
+ class WebRTCTester:
24
+ def __init__(self):
25
+ self.pc = None
26
+ self.session_id = None
27
+ self.video_frames = 0
28
+ self.audio_frames = 0
29
+ self.ice_candidates = []
30
+ self.connected = False
31
+
32
+ async def test_connection(self):
33
+ """Testa conexão WebRTC completa"""
34
+
35
+ print(f"\n{'='*60}")
36
+ print(f"{BLUE}WebRTC Connection Test{RESET}")
37
+ print(f"{'='*60}\n")
38
+
39
+ try:
40
+ # 1. Configurar ICE servers (STUN + TURN)
41
+ print(f"{YELLOW}[1/5]{RESET} Configurando ICE servers...")
42
+ ice_servers = [
43
+ RTCIceServer(urls=["stun:stun.l.google.com:19302"]),
44
+ RTCIceServer(urls=["stun:stun1.l.google.com:19302"]),
45
+ RTCIceServer(
46
+ urls=["turn:openrelay.metered.ca:80"],
47
+ username="openrelayproject",
48
+ credential="openrelayproject"
49
+ ),
50
+ RTCIceServer(
51
+ urls=["turn:openrelay.metered.ca:443"],
52
+ username="openrelayproject",
53
+ credential="openrelayproject"
54
+ ),
55
+ ]
56
+
57
+ config = RTCConfiguration(iceServers=ice_servers)
58
+ self.pc = RTCPeerConnection(configuration=config)
59
+ print(f"{GREEN}✓{RESET} ICE servers configurados\n")
60
+
61
+ # 2. Configurar handlers
62
+ print(f"{YELLOW}[2/5]{RESET} Configurando event handlers...")
63
+
64
+ @self.pc.on("icecandidate")
65
+ async def on_ice_candidate(candidate):
66
+ if candidate:
67
+ self.ice_candidates.append({
68
+ 'type': candidate.candidate.type if hasattr(candidate.candidate, 'type') else 'unknown',
69
+ 'protocol': candidate.candidate.protocol if hasattr(candidate.candidate, 'protocol') else 'unknown'
70
+ })
71
+
72
+ @self.pc.on("connectionstatechange")
73
+ async def on_connection_state():
74
+ state = self.pc.connectionState
75
+ if state == "connected":
76
+ print(f"{GREEN}✓{RESET} WebRTC Estado: {GREEN}CONNECTED{RESET}")
77
+ self.connected = True
78
+ elif state == "failed":
79
+ print(f"{RED}✗{RESET} WebRTC Estado: {RED}FAILED{RESET}")
80
+ elif state == "connecting":
81
+ print(f"{YELLOW}⟳{RESET} WebRTC Estado: CONNECTING...")
82
+ else:
83
+ print(f" WebRTC Estado: {state}")
84
+
85
+ @self.pc.on("iceconnectionstatechange")
86
+ async def on_ice_state():
87
+ state = self.pc.iceConnectionState
88
+ if state == "connected":
89
+ print(f"{GREEN}✓{RESET} ICE Estado: {GREEN}CONNECTED{RESET}")
90
+ elif state == "failed":
91
+ print(f"{RED}✗{RESET} ICE Estado: {RED}FAILED{RESET}")
92
+ elif state == "checking":
93
+ print(f"{YELLOW}⟳{RESET} ICE Estado: CHECKING...")
94
+ else:
95
+ print(f" ICE Estado: {state}")
96
+
97
+ @self.pc.on("track")
98
+ async def on_track(track):
99
+ print(f"{GREEN}✓{RESET} Track recebido: {BLUE}{track.kind}{RESET}")
100
+
101
+ if track.kind == "video":
102
+ while True:
103
+ try:
104
+ frame = await track.recv()
105
+ self.video_frames += 1
106
+ if self.video_frames % 25 == 0: # A cada segundo (25fps)
107
+ print(f" {BLUE}Video:{RESET} {self.video_frames} frames recebidos")
108
+ except Exception as e:
109
+ break
110
+
111
+ elif track.kind == "audio":
112
+ while True:
113
+ try:
114
+ frame = await track.recv()
115
+ self.audio_frames += 1
116
+ if self.audio_frames % 50 == 0: # A cada ~1 segundo
117
+ print(f" {BLUE}Audio:{RESET} {self.audio_frames} frames recebidos")
118
+ except Exception as e:
119
+ break
120
+
121
+ print(f"{GREEN}✓{RESET} Handlers configurados\n")
122
+
123
+ # 3. Criar transceivers
124
+ print(f"{YELLOW}[3/5]{RESET} Criando transceivers...")
125
+ self.pc.addTransceiver("video", direction="recvonly")
126
+ self.pc.addTransceiver("audio", direction="recvonly")
127
+ print(f"{GREEN}✓{RESET} Transceivers criados\n")
128
+
129
+ # 4. Criar offer e enviar para servidor
130
+ print(f"{YELLOW}[4/5]{RESET} Criando offer SDP...")
131
+ offer = await self.pc.createOffer()
132
+ await self.pc.setLocalDescription(offer)
133
+
134
+ # Aguardar ICE gathering
135
+ print(f"{YELLOW}⟳{RESET} Aguardando ICE gathering...")
136
+ await self.wait_for_ice_gathering()
137
+
138
+ print(f"{GREEN}✓{RESET} Offer criado\n")
139
+
140
+ # 5. Enviar offer para servidor
141
+ print(f"{YELLOW}[5/5]{RESET} Enviando offer para servidor...")
142
+ async with aiohttp.ClientSession() as session:
143
+ async with session.post(
144
+ f"{SERVER_URL}/offer",
145
+ json={
146
+ "sdp": self.pc.localDescription.sdp,
147
+ "type": self.pc.localDescription.type
148
+ }
149
+ ) as resp:
150
+ if resp.status != 200:
151
+ raise Exception(f"Erro HTTP {resp.status}")
152
+
153
+ answer = await resp.json()
154
+ self.session_id = answer.get('session_id')
155
+
156
+ # Aplicar answer
157
+ await self.pc.setRemoteDescription(
158
+ RTCSessionDescription(
159
+ sdp=answer["sdp"],
160
+ type=answer["type"]
161
+ )
162
+ )
163
+
164
+ print(f"{GREEN}✓{RESET} Answer recebido")
165
+ print(f"{GREEN}✓{RESET} Session ID: {BLUE}{self.session_id}{RESET}\n")
166
+
167
+ # 6. Aguardar conexão
168
+ print(f"{YELLOW}⟳{RESET} Aguardando conexão WebRTC...\n")
169
+
170
+ # Aguardar até 15 segundos
171
+ for i in range(15):
172
+ await asyncio.sleep(1)
173
+ if self.connected:
174
+ break
175
+
176
+ # 7. Mostrar resultados
177
+ await self.show_results()
178
+
179
+ # 8. Manter vivo por 10 segundos para receber frames
180
+ if self.connected:
181
+ print(f"\n{YELLOW}⟳{RESET} Monitorando recepção de frames por 10 segundos...\n")
182
+ await asyncio.sleep(10)
183
+
184
+ # 9. Resultados finais
185
+ await self.show_final_results()
186
+
187
+ except Exception as e:
188
+ print(f"\n{RED}✗ ERRO:{RESET} {e}\n")
189
+ import traceback
190
+ traceback.print_exc()
191
+ return False
192
+
193
+ finally:
194
+ if self.pc:
195
+ await self.pc.close()
196
+
197
+ return self.connected
198
+
199
+ async def wait_for_ice_gathering(self):
200
+ """Aguarda ICE gathering completar"""
201
+ max_wait = 5
202
+ for _ in range(max_wait * 10):
203
+ if self.pc.iceGatheringState == "complete":
204
+ return
205
+ await asyncio.sleep(0.1)
206
+
207
+ async def show_results(self):
208
+ """Mostra resultados da conexão"""
209
+ print(f"\n{'='*60}")
210
+ print(f"{BLUE}Resultados da Conexão{RESET}")
211
+ print(f"{'='*60}\n")
212
+
213
+ # Estado da conexão
214
+ conn_state = self.pc.connectionState
215
+ if conn_state == "connected":
216
+ print(f"WebRTC State: {GREEN}✓ CONNECTED{RESET}")
217
+ elif conn_state == "failed":
218
+ print(f"WebRTC State: {RED}✗ FAILED{RESET}")
219
+ else:
220
+ print(f"WebRTC State: {YELLOW}{conn_state.upper()}{RESET}")
221
+
222
+ # Estado ICE
223
+ ice_state = self.pc.iceConnectionState
224
+ if ice_state == "connected":
225
+ print(f"ICE State: {GREEN}✓ CONNECTED{RESET}")
226
+ elif ice_state == "failed":
227
+ print(f"ICE State: {RED}✗ FAILED{RESET}")
228
+ else:
229
+ print(f"ICE State: {YELLOW}{ice_state.upper()}{RESET}")
230
+
231
+ # Candidatos ICE
232
+ print(f"\nICE Candidates: {len(self.ice_candidates)} gerados")
233
+
234
+ candidate_types = {}
235
+ for c in self.ice_candidates:
236
+ ctype = c['type']
237
+ candidate_types[ctype] = candidate_types.get(ctype, 0) + 1
238
+
239
+ for ctype, count in candidate_types.items():
240
+ icon = "✓" if ctype == "relay" else "•"
241
+ color = GREEN if ctype == "relay" else BLUE
242
+ print(f" {color}{icon}{RESET} {ctype}: {count}")
243
+
244
+ # Session ID
245
+ if self.session_id:
246
+ print(f"\nSession ID: {BLUE}{self.session_id}{RESET}")
247
+
248
+ print()
249
+
250
+ async def show_final_results(self):
251
+ """Mostra resultados finais"""
252
+ print(f"\n{'='*60}")
253
+ print(f"{BLUE}Resultados Finais{RESET}")
254
+ print(f"{'='*60}\n")
255
+
256
+ if self.connected:
257
+ print(f"Status: {GREEN}✓ SUCESSO{RESET}")
258
+ else:
259
+ print(f"Status: {RED}✗ FALHA{RESET}")
260
+
261
+ print(f"Video Frames: {BLUE}{self.video_frames}{RESET}")
262
+ print(f"Audio Frames: {BLUE}{self.audio_frames}{RESET}")
263
+
264
+ if self.video_frames > 0 and self.audio_frames > 0:
265
+ print(f"\n{GREEN}✓ WebRTC funcionando perfeitamente!{RESET}")
266
+ elif self.connected:
267
+ print(f"\n{YELLOW}⚠ Conectado mas sem receber frames{RESET}")
268
+ else:
269
+ print(f"\n{RED}✗ Falha na conexão WebRTC{RESET}")
270
+
271
+ print()
272
+
273
+
274
+ async def main():
275
+ tester = WebRTCTester()
276
+ success = await tester.test_connection()
277
+ sys.exit(0 if success else 1)
278
+
279
+
280
+ if __name__ == "__main__":
281
+ asyncio.run(main())
interface/test_webrtc_playwright.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Teste WebRTC automatizado com Playwright headless
4
+ """
5
+
6
+ import asyncio
7
+ from playwright.async_api import async_playwright
8
+ import sys
9
+
10
+ SERVER_URL = "http://62.107.25.198:47898"
11
+
12
+ async def test_webrtc():
13
+ print("="*60)
14
+ print("Teste WebRTC com Playwright (Headless)")
15
+ print("="*60)
16
+
17
+ async with async_playwright() as p:
18
+ # Iniciar browser headless
19
+ print("\n[1/6] Iniciando browser headless...")
20
+ browser = await p.chromium.launch(
21
+ headless=True,
22
+ args=[
23
+ '--use-fake-ui-for-media-stream',
24
+ '--use-fake-device-for-media-stream',
25
+ '--no-sandbox'
26
+ ]
27
+ )
28
+
29
+ context = await browser.new_context(
30
+ permissions=['camera', 'microphone']
31
+ )
32
+
33
+ page = await context.new_page()
34
+
35
+ # Coletar logs do console e erros
36
+ console_logs = []
37
+ page_errors = []
38
+
39
+ page.on("console", lambda msg: console_logs.append(f"[{msg.type}] {msg.text}"))
40
+ page.on("pageerror", lambda exc: page_errors.append(f"ERROR: {exc}"))
41
+
42
+ # Capturar requests/responses
43
+ network_logs = []
44
+
45
+ def handle_response(response):
46
+ if '/offer' in response.url:
47
+ network_logs.append(f"POST /offer → Status: {response.status}")
48
+ if response.status != 200:
49
+ network_logs.append(f" Error: {response.status_text}")
50
+
51
+ page.on("response", handle_response)
52
+
53
+ # Navegar para a página
54
+ print(f"[2/6] Navegando para {SERVER_URL}...")
55
+ try:
56
+ await page.goto(SERVER_URL, timeout=10000)
57
+ print("✓ Página carregada")
58
+ except Exception as e:
59
+ print(f"✗ Erro ao carregar página: {e}")
60
+ await browser.close()
61
+ return False
62
+
63
+ # Verificar se a página carregou
64
+ print("[3/6] Verificando elementos...")
65
+ try:
66
+ await page.wait_for_selector('#btnConnect', timeout=5000)
67
+ print("✓ Botão 'Conectar' encontrado")
68
+ except Exception as e:
69
+ print(f"✗ Erro: {e}")
70
+ await browser.close()
71
+ return False
72
+
73
+ # Injetar código para capturar eventos ICE
74
+ print("[4/6] Configurando captura de eventos ICE...")
75
+ await page.evaluate("""
76
+ window.iceInfo = {
77
+ candidates: [],
78
+ states: [],
79
+ connectionStates: []
80
+ };
81
+
82
+ // Interceptar console.log de ICE candidates
83
+ const originalLog = console.log;
84
+ console.log = function(...args) {
85
+ if (args[0] === 'ICE Candidate:') {
86
+ window.iceInfo.candidates.push(args[1]);
87
+ } else if (args[0] === 'ICE State:') {
88
+ window.iceInfo.states.push(args[1]);
89
+ } else if (args[0] === 'Estado WebRTC:') {
90
+ window.iceInfo.connectionStates.push(args[1]);
91
+ }
92
+ originalLog.apply(console, args);
93
+ };
94
+ """)
95
+
96
+ # Clicar em conectar
97
+ print("[5/6] Clicando em 'Conectar'...")
98
+ await page.click('#btnConnect')
99
+
100
+ # Aguardar tentativa de conexão
101
+ print("[6/6] Aguardando conexão (15s)...")
102
+ await asyncio.sleep(15)
103
+
104
+ # Capturar informações ICE
105
+ ice_info = await page.evaluate("window.iceInfo")
106
+
107
+ # Capturar estado da conexão
108
+ webrtc_state = await page.evaluate("""
109
+ (() => {
110
+ const pc = window.pc;
111
+ if (!pc) return null;
112
+ return {
113
+ connectionState: pc.connectionState,
114
+ iceConnectionState: pc.iceConnectionState,
115
+ iceGatheringState: pc.iceGatheringState,
116
+ signalingState: pc.signalingState
117
+ };
118
+ })()
119
+ """)
120
+
121
+ # Resultados
122
+ print("\n" + "="*60)
123
+ print("RESULTADOS")
124
+ print("="*60)
125
+
126
+ if webrtc_state:
127
+ print(f"\nEstados WebRTC:")
128
+ print(f" Connection: {webrtc_state['connectionState']}")
129
+ print(f" ICE Connection: {webrtc_state['iceConnectionState']}")
130
+ print(f" ICE Gathering: {webrtc_state['iceGatheringState']}")
131
+ print(f" Signaling: {webrtc_state['signalingState']}")
132
+ else:
133
+ print("\n✗ Peer connection não foi criada!")
134
+
135
+ # Análise de candidatos ICE
136
+ candidates = ice_info.get('candidates', [])
137
+ print(f"\nCandidatos ICE: {len(candidates)}")
138
+
139
+ candidate_types = {}
140
+ for c in candidates:
141
+ ctype = c.get('type', 'unknown')
142
+ candidate_types[ctype] = candidate_types.get(ctype, 0) + 1
143
+
144
+ for ctype, count in candidate_types.items():
145
+ icon = "✓" if ctype == "relay" else "•"
146
+ print(f" {icon} {ctype}: {count}")
147
+
148
+ # Diagnóstico
149
+ print("\n" + "="*60)
150
+ print("DIAGNÓSTICO")
151
+ print("="*60)
152
+
153
+ success = False
154
+
155
+ if not webrtc_state:
156
+ print("\n✗ FALHA: Peer connection não foi criada")
157
+ print(" → Verificar JavaScript do frontend")
158
+
159
+ elif webrtc_state['connectionState'] == 'connected':
160
+ print("\n✓ SUCESSO: WebRTC conectado!")
161
+ success = True
162
+
163
+ elif webrtc_state['iceConnectionState'] == 'failed':
164
+ print("\n✗ FALHA: ICE connection failed")
165
+
166
+ if len(candidates) == 0:
167
+ print(" → Problema: Nenhum candidato ICE gerado")
168
+ print(" → Solução: Verificar configuração STUN/TURN")
169
+
170
+ elif 'relay' not in candidate_types:
171
+ print(" → Problema: Nenhum candidato TURN (relay)")
172
+ print(" → Solução: Servidores TURN não estão funcionando")
173
+ print(" → Tentar outros servidores TURN")
174
+
175
+ else:
176
+ print(" → Problema: Candidatos gerados mas não conectam")
177
+ print(" → Solução: Problema de firewall/NAT no servidor")
178
+ print(" → Verificar port mapping UDP no vast.ai")
179
+
180
+ elif webrtc_state['connectionState'] == 'connecting':
181
+ print("\n⚠ TIMEOUT: Conexão travada em 'connecting'")
182
+ print(" → Problema: ICE negotiation timeout")
183
+ print(" → Causa provável: Port mapping UDP não configurado")
184
+ print(" → Solução: Expor portas UDP no vast.ai ou usar ngrok")
185
+
186
+ # Erros de página
187
+ if page_errors:
188
+ print("\n" + "="*60)
189
+ print("ERROS JAVASCRIPT")
190
+ print("="*60)
191
+ for err in page_errors:
192
+ print(err)
193
+
194
+ # Logs de rede
195
+ if network_logs:
196
+ print("\n" + "="*60)
197
+ print("LOGS DE REDE")
198
+ print("="*60)
199
+ for log in network_logs:
200
+ print(log)
201
+
202
+ # Logs do console
203
+ if console_logs:
204
+ print("\n" + "="*60)
205
+ print("LOGS DO CONSOLE (últimos 20)")
206
+ print("="*60)
207
+ for log in console_logs[-20:]:
208
+ print(log)
209
+
210
+ await browser.close()
211
+ return success
212
+
213
+ async def main():
214
+ success = await test_webrtc()
215
+ sys.exit(0 if success else 1)
216
+
217
+ if __name__ == "__main__":
218
+ asyncio.run(main())
interface/webrtc_skypilot.yaml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: webrtc-avatar
2
+
3
+ resources:
4
+ cloud: vast
5
+ accelerators: RTX_5090
6
+ disk_size: 200
7
+ ports:
8
+ - 8080 # HTTP + WebRTC
9
+
10
+ workdir: .
11
+
12
+ setup: |
13
+ set -e
14
+ sudo apt-get update -qq
15
+ sudo apt-get install -y ffmpeg libavdevice-dev libavfilter-dev libopus-dev libvpx-dev libsrtp2-dev
16
+ pip install aiohttp aiortc opencv-python numpy av websockets -q
17
+
18
+ run: |
19
+ python3 server.py