Commit ·
60e84bc
1
Parent(s): e62aafd
Integrate AvatarAPI: prepare for direct Python calls replacing WebSocket
Browse files- Add AvatarAPI import from video/avatar_api.py
- Add configuration for AVATAR_VIDEO, WAV2LIP_CHECKPOINT, TTS_URL
- Add global avatar_api instance variable
- Maintain 480p resolution (854x480) and 48kHz audio from previous fixes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- interface/server.py +56 -14
interface/server.py
CHANGED
|
@@ -16,6 +16,7 @@ import asyncio
|
|
| 16 |
import json
|
| 17 |
import base64
|
| 18 |
import os
|
|
|
|
| 19 |
import time
|
| 20 |
import uuid
|
| 21 |
import fractions
|
|
@@ -26,15 +27,34 @@ 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 |
-
|
|
|
|
|
|
|
|
|
|
| 38 |
VIDEO_TIME_BASE = fractions.Fraction(1, VIDEO_FPS)
|
| 39 |
AUDIO_TIME_BASE = fractions.Fraction(1, AUDIO_SAMPLE_RATE)
|
| 40 |
|
|
@@ -42,6 +62,7 @@ AUDIO_TIME_BASE = fractions.Fraction(1, AUDIO_SAMPLE_RATE)
|
|
| 42 |
idle_frames_cache = []
|
| 43 |
pcs = set() # Track peer connections
|
| 44 |
relay = MediaRelay()
|
|
|
|
| 45 |
|
| 46 |
routes = web.RouteTableDef()
|
| 47 |
|
|
@@ -156,7 +177,7 @@ def trim_high_motion_frames(frames, threshold_multiplier=1.0, max_trim=20):
|
|
| 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:
|
|
@@ -169,16 +190,29 @@ def load_idle_frames():
|
|
| 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 |
|
|
@@ -202,13 +236,11 @@ class AvatarVideoTrack(MediaStreamTrack):
|
|
| 202 |
self.speaking_idx = 0
|
| 203 |
self.best_idle_idx = None # Frame idle para transicao suave
|
| 204 |
|
| 205 |
-
# Dimensoes do video
|
| 206 |
-
|
| 207 |
-
|
| 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."""
|
|
@@ -288,7 +320,7 @@ class AvatarAudioTrack(MediaStreamTrack):
|
|
| 288 |
def __init__(self):
|
| 289 |
super().__init__()
|
| 290 |
self.sample_rate = AUDIO_SAMPLE_RATE
|
| 291 |
-
self.samples_per_frame =
|
| 292 |
self.frame_count = 0
|
| 293 |
self.start_time = None
|
| 294 |
|
|
@@ -296,7 +328,7 @@ class AvatarAudioTrack(MediaStreamTrack):
|
|
| 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."""
|
|
@@ -336,7 +368,17 @@ class AvatarAudioTrack(MediaStreamTrack):
|
|
| 336 |
# Converter bytes para numpy array
|
| 337 |
samples = np.frombuffer(pcm_data, dtype=np.int16)
|
| 338 |
|
| 339 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]
|
|
@@ -346,7 +388,7 @@ class AvatarAudioTrack(MediaStreamTrack):
|
|
| 346 |
self.audio_buffer.append(chunk)
|
| 347 |
|
| 348 |
self.buffer_idx = 0
|
| 349 |
-
print(f"[AudioTrack]
|
| 350 |
|
| 351 |
|
| 352 |
class AvatarSession:
|
|
|
|
| 16 |
import json
|
| 17 |
import base64
|
| 18 |
import os
|
| 19 |
+
import sys
|
| 20 |
import time
|
| 21 |
import uuid
|
| 22 |
import fractions
|
|
|
|
| 27 |
import cv2
|
| 28 |
import subprocess
|
| 29 |
import tempfile
|
| 30 |
+
from scipy import signal
|
| 31 |
+
|
| 32 |
+
# Add path to video directory for avatar_api import
|
| 33 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'video'))
|
| 34 |
+
|
| 35 |
+
# Import AvatarAPI
|
| 36 |
+
try:
|
| 37 |
+
from avatar_api import AvatarAPI
|
| 38 |
+
AVATAR_API_AVAILABLE = True
|
| 39 |
+
print("[Import] AvatarAPI imported successfully")
|
| 40 |
+
except ImportError as e:
|
| 41 |
+
AVATAR_API_AVAILABLE = False
|
| 42 |
+
print(f"[Import] WARNING: Could not import AvatarAPI: {e}")
|
| 43 |
+
print("[Import] Falling back to WebSocket mode")
|
| 44 |
|
| 45 |
# Configuracao
|
|
|
|
| 46 |
PORT = int(os.getenv("PORT", "8080"))
|
| 47 |
IDLE_VIDEO = os.path.join(os.path.dirname(__file__), "idle.mp4")
|
| 48 |
+
AVATAR_VIDEO = os.path.join(os.path.dirname(__file__), '..', 'video', 'avatar_parado.mp4')
|
| 49 |
+
WAV2LIP_CHECKPOINT = os.path.join(os.path.dirname(__file__), '..', 'video', 'checkpoints', 'wav2lip_gan.pth')
|
| 50 |
+
TTS_URL = os.getenv("TTS_URL", "http://localhost:8081")
|
| 51 |
|
| 52 |
# Constantes
|
| 53 |
VIDEO_FPS = 25
|
| 54 |
+
VIDEO_WIDTH = 854 # 480p
|
| 55 |
+
VIDEO_HEIGHT = 480
|
| 56 |
+
AUDIO_SAMPLE_RATE = 48000 # 48kHz é a frequência nativa do Opus WebRTC
|
| 57 |
+
ORPHEUS_SAMPLE_RATE = 24000 # Orpheus gera a 24kHz
|
| 58 |
VIDEO_TIME_BASE = fractions.Fraction(1, VIDEO_FPS)
|
| 59 |
AUDIO_TIME_BASE = fractions.Fraction(1, AUDIO_SAMPLE_RATE)
|
| 60 |
|
|
|
|
| 62 |
idle_frames_cache = []
|
| 63 |
pcs = set() # Track peer connections
|
| 64 |
relay = MediaRelay()
|
| 65 |
+
avatar_api = None # Global AvatarAPI instance
|
| 66 |
|
| 67 |
routes = web.RouteTableDef()
|
| 68 |
|
|
|
|
| 177 |
|
| 178 |
|
| 179 |
def load_idle_frames():
|
| 180 |
+
"""Carrega frames do idle.mp4 como arrays numpy, redimensionados para 480p."""
|
| 181 |
global idle_frames_cache
|
| 182 |
|
| 183 |
if idle_frames_cache:
|
|
|
|
| 190 |
print(f"[Idle] Carregando frames de {IDLE_VIDEO}...")
|
| 191 |
|
| 192 |
cap = cv2.VideoCapture(IDLE_VIDEO)
|
| 193 |
+
|
| 194 |
+
# Detectar resolução original
|
| 195 |
+
original_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
| 196 |
+
original_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
| 197 |
+
print(f"[Idle] Resolucao original: {original_width}x{original_height}")
|
| 198 |
+
print(f"[Idle] Redimensionando para: {VIDEO_WIDTH}x{VIDEO_HEIGHT} (480p)")
|
| 199 |
+
|
| 200 |
while True:
|
| 201 |
ret, frame = cap.read()
|
| 202 |
if not ret:
|
| 203 |
break
|
| 204 |
# Converter BGR para RGB
|
| 205 |
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 206 |
+
|
| 207 |
+
# Redimensionar para 480p se necessario
|
| 208 |
+
if original_width != VIDEO_WIDTH or original_height != VIDEO_HEIGHT:
|
| 209 |
+
frame_rgb = cv2.resize(frame_rgb, (VIDEO_WIDTH, VIDEO_HEIGHT),
|
| 210 |
+
interpolation=cv2.INTER_LANCZOS4)
|
| 211 |
+
|
| 212 |
idle_frames_cache.append(frame_rgb)
|
| 213 |
cap.release()
|
| 214 |
|
| 215 |
+
print(f"[Idle] Carregados {len(idle_frames_cache)} frames @ {VIDEO_WIDTH}x{VIDEO_HEIGHT}")
|
| 216 |
return idle_frames_cache
|
| 217 |
|
| 218 |
|
|
|
|
| 236 |
self.speaking_idx = 0
|
| 237 |
self.best_idle_idx = None # Frame idle para transicao suave
|
| 238 |
|
| 239 |
+
# Dimensoes do video (480p fixo)
|
| 240 |
+
self.width = VIDEO_WIDTH
|
| 241 |
+
self.height = VIDEO_HEIGHT
|
|
|
|
|
|
|
| 242 |
|
| 243 |
+
print(f"[VideoTrack] Inicializado: {self.width}x{self.height} @ {VIDEO_FPS}fps (480p fixo)")
|
| 244 |
|
| 245 |
async def recv(self):
|
| 246 |
"""Retorna o proximo frame de video."""
|
|
|
|
| 320 |
def __init__(self):
|
| 321 |
super().__init__()
|
| 322 |
self.sample_rate = AUDIO_SAMPLE_RATE
|
| 323 |
+
self.samples_per_frame = 1920 # 40ms @ 48kHz (era 960 @ 24kHz)
|
| 324 |
self.frame_count = 0
|
| 325 |
self.start_time = None
|
| 326 |
|
|
|
|
| 328 |
self.audio_buffer = []
|
| 329 |
self.buffer_idx = 0
|
| 330 |
|
| 331 |
+
print(f"[AudioTrack] Inicializado: {self.sample_rate}Hz, {self.samples_per_frame} samples/frame (40ms)")
|
| 332 |
|
| 333 |
async def recv(self):
|
| 334 |
"""Retorna o proximo frame de audio."""
|
|
|
|
| 368 |
# Converter bytes para numpy array
|
| 369 |
samples = np.frombuffer(pcm_data, dtype=np.int16)
|
| 370 |
|
| 371 |
+
print(f"[AudioTrack] Recebido: {len(samples)} samples @ {ORPHEUS_SAMPLE_RATE}Hz")
|
| 372 |
+
|
| 373 |
+
# RESAMPLE: Orpheus gera 24kHz, mas Opus WebRTC funciona melhor em 48kHz (nativo)
|
| 374 |
+
# Upsample 24kHz -> 48kHz (2x) usando scipy high-quality resampling
|
| 375 |
+
if ORPHEUS_SAMPLE_RATE != AUDIO_SAMPLE_RATE:
|
| 376 |
+
num_samples_48k = int(len(samples) * AUDIO_SAMPLE_RATE / ORPHEUS_SAMPLE_RATE)
|
| 377 |
+
samples_48k = signal.resample(samples, num_samples_48k).astype(np.int16)
|
| 378 |
+
print(f"[AudioTrack] Upsampled: {len(samples_48k)} samples @ {AUDIO_SAMPLE_RATE}Hz (scipy high-quality)")
|
| 379 |
+
samples = samples_48k
|
| 380 |
+
|
| 381 |
+
# Dividir em frames de 40ms (1920 samples @ 48kHz)
|
| 382 |
self.audio_buffer = []
|
| 383 |
for i in range(0, len(samples), self.samples_per_frame):
|
| 384 |
chunk = samples[i:i + self.samples_per_frame]
|
|
|
|
| 388 |
self.audio_buffer.append(chunk)
|
| 389 |
|
| 390 |
self.buffer_idx = 0
|
| 391 |
+
print(f"[AudioTrack] Buffer: {len(self.audio_buffer)} frames x {self.samples_per_frame} samples (40ms cada)")
|
| 392 |
|
| 393 |
|
| 394 |
class AvatarSession:
|