@ :~/lab/avatar-parla-prima-di-muovere-labbra$
← cd ~/lab

./run avatar-parla-prima-di-muovere-labbra

L'avatar che parla prima di muovere le labbra

Diario di un avatar AI conversazionale in tempo reale: STT, LLM, TTS e lip-sync su GPU. La decisione dura è stata sganciare l'audio dal video.

status
draft
project
ai-avatar-POC
updated
2026-07-07
tags
#avatar#lip-sync#musetalk#webrtc#latenza#llm
generate cover # alt: Schema di una pipeline vocale in tempo reale: microfono, trascrizione, LLM, sintesi vocale e volto animato in lip-sync

Parli. Un attimo di silenzio, e un volto sullo schermo ti risponde a voce, muovendo le labbra a tempo. È l’idea. La realtà è che il pezzo più lento della catena — il lip-sync su GPU — voleva prendersi in ostaggio tutto il resto. La spina di questo POC è la decisione con cui gli ho tolto quel potere: l’avatar parla prima di sapere come muovere la bocca.

L’idea

Un avatar AI conversazionale full-duplex: tu parli, lui risponde con voce e video, in tempo (quasi) reale. La catena è lunga e ogni anello ha una sua API:

  1. VAD in browser rileva quando smetti di parlare
  2. STT — Whisper trascrive
  3. LLM — Claude genera la risposta
  4. TTS — ElevenLabs la sintetizza in voce
  5. Lip-sync — MuseTalk anima un volto a partire da quell’audio

Cinque salti, quattro dei quali sono chiamate di rete a servizi esterni, e l’ultimo è un modello di diffusione che gira su GPU. Sommati ingenuamente, sono secondi di attesa con lo schermo fermo. Inaccettabile per qualcosa che si spaccia per “conversazione”.

Architettura: due servizi, una separazione netta

Ho spezzato il sistema in due processi che parlano HTTP tra loro:

  • Orchestrator (FastAPI, Python 3.11): tiene la WebSocket col browser e coordina STT + LLM + TTS. Nessun peso computazionale, solo colla e chiamate API.
  • MuseTalk service (FastAPI dentro un container Podman, su GPU H100): un unico endpoint /generate che prende avatar_id + audio e restituisce un MP4 con le labbra sincronizzate.

L’orchestrator non sa niente di CUDA, il servizio GPU non sa niente di Claude o ElevenLabs. Se il pezzo pesante cade, il resto continua a funzionare — e questo diventa la chiave di tutto.

Il browser manda audio grezzo (PCM 16 kHz mono) sulla WebSocket solo dopo che il VAD ha deciso che hai finito la frase. Uso Silero VAD via @ricky0123/vad-react, tarato stretto per non attivarsi sul rumore:

const vad = useMicVAD({
  positiveSpeechThreshold: 0.8,
  negativeSpeechThreshold: 0.3,
  minSpeechFrames: 5,
  redemptionFrames: 8,
  onSpeechEnd: (audio) => {
    if (status === "processing" || status === "speaking") return; // no barge-in
    wsRef.current.send(float32ToPCM16(audio).buffer);
  },
});

La decisione dura: sganciare l’audio dal video

L’istinto sarebbe: aspetta che il video sia pronto e mandalo. Ma il video è l’anello lento e capriccioso. Così ho ribaltato l’ordine. L’handler della WebSocket manda ogni risultato appena ce l’ha, in streaming, e tratta il video come un miglioramento opzionale, non come un requisito:

# STT → trascrizione subito
transcript = await self.pipeline.transcribe(wav_buffer)
await self._send({"type": "transcript", "text": transcript})

# LLM → testo della risposta subito
response_text = await self.pipeline.generate_response(transcript)
await self._send({"type": "response", "text": response_text})

# TTS → l'utente SENTE la risposta ORA
audio_bytes, audio_id = await self.pipeline.synthesize_speech(response_text)
await self._send({"type": "audio_url", "url": f"/api/audio/{audio_id}"})

# Video → se fallisce, l'utente ha già l'audio (degrada con grazia)
try:
    video_id = await self.pipeline.generate_avatar_video(self.avatar_id, audio_bytes)
    await self._send({"type": "video_ready", "url": f"/api/video/{video_id}"})
except Exception as e:
    logger.warning("Video generation failed (audio already sent): %s", e)

L’effetto percepito: senti la voce rispondere quasi subito, mostrando intanto la foto statica dell’avatar; quando il video arriva, il player passa dalla foto al clip animato. Se la GPU è occupata, in errore, o semplicemente troppo lenta, non te ne accorgi: hai comunque una conversazione vocale completa. Il video è la ciliegia, non la torta.

Perché il video sia sostenibile, l’ho anche accorciato alla fonte. Il system prompt di Claude è brutale e max_tokens è fissato a 150:

IMPORTANTE: Rispondi SEMPRE in massimo 1-2 frasi brevi (max 30 parole).
Le risposte lunghe causano latenza nel video. Sii conciso ma cordiale.

Ogni parola in più è un fotogramma in più da generare a 25 fps. La concisione qui non è stile: è budget di latenza.

Sul lato GPU, il trucco per non pagare due volte è il preprocessing. Alla prima foto caricata faccio detection del volto (DWPose), face parsing per la maschera di blending e VAE-encode del crop 256×256, poi salvo tutto in un pickle. In fase di /generate carico i latenti già pronti e resta solo l’inferenza UNet, in batch da 32 fotogrammi, in fp16:

with torch.no_grad():
    pred_latents = self.unet.model(
        latent_batch, timesteps,
        encoder_hidden_states=audio_features,   # feature Whisper del TTS
    ).sample

Gotcha onesti

  • PyTorch 2.0.1 non parla con l’H100. Il default di MuseTalk (Torch 2.0.1 + CUDA 11.7) ignora l’architettura sm_90. Ho dovuto ricostruire tutto su immagine NGC PyTorch 24.01 (Torch 2.2 + CUDA 12.3). Mezza giornata bruciata a capire perché la GPU più potente del parco fosse quella che non funzionava.
  • Due Whisper diversi. Uno è l’API di OpenAI (whisper-1) che usa l’orchestrator per lo STT. L’altro è un Whisper tiny interno a MuseTalk, che estrae feature audio per guidare il lip-sync. Stesso nome, due mondi. Averlo capito tardi mi è costato un debug surreale.
  • Il roundtrip MP3→WAV. ElevenLabs mi dà MP3 (comodo per il browser), ma MuseTalk vuole WAV a 16 kHz. Quindi ri-decodifico con un ffmpeg in subprocess a ogni turno. Spreco puro, ma isolarlo dentro il container mi ha evitato conflitti di GLIBC con l’host (dentro uso il ffmpeg impacchettato da imageio).
  • cv2 rotto nell’immagine base. L’immagine NGC ha un OpenCV mezzo compilato: ho dovuto disinstallarlo e reinstallare opencv-python-headless con --no-deps per non farmi ri-aggiornare numpy sotto i piedi.
  • “Real-time” con l’asterisco. Il video è generato come file intero e poi servito: è request/response, non uno stream di fotogrammi. Finché non trovo lo streaming a frame, la latenza-video resta pari alla generazione completa. È esattamente per questo che l’ho reso opzionale.
  • Drift codice/codice. pipeline.py espone un bel metodo process_speech() che fa l’intera catena in sequenza — ma la WebSocket non lo usa: richiama i singoli step a mano proprio per poter fare streaming. Il metodo “pulito” è un fossile della prima versione bloccante.

Come va

È un POC, dichiarato tale. I due servizi girano, la conversazione vocale è fluida, il lip-sync funziona quando la GPU è libera. Ci sono inefficienze che vedo e non ho ancora tolto: nel loop di generazione ricalcolo gli stessi latenti VAE per ogni fotogramma invece di riusarli, e la history di conversazione la taglio in modo grezzo (tengo gli ultimi 16 messaggi quando supero i 20). Il prossimo salto vero è lo streaming del video a chunk, non il file monolitico.

Cosa ho imparato

Che in una pipeline in tempo reale la domanda giusta non è “quanto posso rendere veloce l’anello lento”, ma “come faccio a non dipendere dall’anello lento”. Trattare il lip-sync come un enhancement invece che come un requisito ha cambiato tutto: la percezione di reattività non viene dal componente più costoso, viene dal primo bit utile che riesci a consegnare. Meglio una voce immediata su una foto ferma che un video perfetto tre secondi dopo. La grazia con cui degradi conta più del picco che raggiungi.