Skip to content

VoiceBot · VoiceChatHandler · FEAT-245 — Guía de Frontend para Conversación Realtime con Agentes

Objetivo de este documento: dar a un equipo de frontend todo lo necesario para construir una UI full-interactive, realtime, basada en WebSocket que hable con un agente de AI-Parrot por voz — incluyendo el modo con avatar con lip-sync (FEAT-245).

Cubre tres capas: 1. VoiceBot — el cerebro de voz (Gemini Live, audio nativo bidireccional). 2. VoiceChatHandler — el handler aiohttp que expone /ws/voice al navegador. 3. FEAT-245 — el "tee" de audio que mueve la boca de un avatar LiveAvatar/LiveKit.

Todo lo descrito aquí está verificado contra el código en packages/ai-parrot y packages/ai-parrot-integrations (estado: dev, junio 2026).


0. Mapa mental en 30 segundos

                          ┌──────────────────────────── Browser (tu UI) ───────────────────────────┐
   Micrófono (16 kHz PCM) │  getUserMedia → AudioWorklet → PCM16 16k                                 │
        ──────────────────┼──▶ WebSocket /ws/voice (binario o base64)                               │
                          │                                                                          │
   Altavoz (24 kHz PCM)   │  ◀── response_chunk {audio_base64} ── Web Audio API (cola de reproducción)│
        ◀─────────────────┤                                                                          │
   Avatar vídeo+audio     │  ◀── LiveKit room (subscribe-only token)  ←─ SOLO si avatar:true         │
        ◀─────────────────┘                                                                          │
                          └──────────────────────────────────────────────────────────────────────────┘
                                              │  ▲
                                  /ws/voice   │  │  response_chunk / transcription / tool_call ...
                                              ▼  │
                          ┌───────────────────────────────────────────────┐
                          │  VoiceChatHandler (aiohttp)                     │
                          │   · auth JWT · sesión · cola de audio           │
                          │   · streaming | buffered                        │
                          └───────────────────────────────────────────────┘
                                              │  ▲
                              VoiceBot.ask_stream(audio_iter)  │
                                              ▼  │  LiveVoiceResponse(audio 24k, text, tool_calls)
                          ┌───────────────────────────────────────────────┐
                          │  VoiceBot → GeminiLiveClient                    │
                          │   · Gemini 2.5 native-audio                     │
                          │   · VAD · STT in/out · tools · interrupción     │
                          └───────────────────────────────────────────────┘
                                              │  (FEAT-245: tee del MISMO audio 24k)
                          VoiceAvatarSession.speak(pcm) → AvatarWebSocket → LiveAvatar → LiveKit room

Las tres reglas de oro del frontend:

  1. Entrada: micrófono → PCM 16-bit, 16 kHz, mono → enviar como binario (preferido) o base64.
  2. Salida: el servidor manda PCM 16-bit, 24 kHz, mono dentro de response_chunk.audio_base64.
  3. Avatar (FEAT-245): si pides avatar:true, el navegador recibe el audio dos veces (por /ws/voice y por la sala LiveKit). Debes silenciar una fuente para evitar eco — recomendado: silenciar el audio de /ws/voice y escuchar la pista del avatar (la que tiene lip-sync).

1. VoiceBot — el cerebro de voz

Archivo: packages/ai-parrot/src/parrot/bots/voice.py

class VoiceBot(A2AEnabledMixin, BaseBot):

VoiceBot es un bot de AI-Parrot especializado en voz. Internamente siempre usa GeminiLiveClient (Gemini 2.5 con audio nativo), independientemente del llm que pases.

1.1 Construcción

from parrot.bots.voice import VoiceBot
from parrot.models.voice import VoiceConfig

bot = VoiceBot(
    name="Sales Assistant",
    system_prompt="Eres un asistente de ventas conciso...",
    tools=[SearchTool(), RecommendTool()],
    voice_config=VoiceConfig(
        voice_name="Puck",        # Puck, Charon, Kore, Aoede, Fenrir...
        language="en-US",
        temperature=0.7,
        max_tokens=4096,
        enable_vad=True,                      # detección de turno automática
        enable_input_transcription=True,      # STT del usuario
        enable_output_transcription=True,     # STT del asistente
    ),
)
await bot.configure()   # inicialización async (memoria Redis por defecto)

VoiceConfig (packages/ai-parrot/src/parrot/models/voice.py):

Campo Default Nota
voice_name "Puck" voz prebuilt de Gemini
language "en-US" BCP-47
input_format PCM_16K lo que el navegador debe enviar
output_format PCM_24K lo que el servidor devuelve
temperature 0.7
max_tokens 4096
enable_vad True turn-taking automático
enable_input_transcription True habilita evento transcription (usuario)
enable_output_transcription True transcripción del asistente

1.2 Métodos clave (lo que consume el handler)

Método Firma (resumida) Uso
ask_stream(audio_input, session_id, user_id) AsyncIterator[LiveVoiceResponse] streaming bidireccional — el corazón del modo realtime
ask_voice(audio_input, ...) -> LiveVoiceResponse one-shot: buffer completo → respuesta completa (modo buffered)
ask(question: str, ...) AsyncIterator[LiveVoiceResponse] texto → voz (TTS / pruebas)
configure(app=None) -> None inicialización async; memoria Redis por defecto

ask_stream acepta como audio_input un bytes completo o un AsyncIterator[bytes] de chunks PCM16/16k. El handler le pasa un generador que lee de una asyncio.Queue alimentada por los frames que llegan del WebSocket.

1.3 LiveVoiceResponse — el objeto que fluye en cada chunk

Archivo: packages/ai-parrot/src/parrot/clients/live.py

@dataclass
class LiveVoiceResponse:
    text: str = ""
    audio_data: Optional[bytes] = None
    audio_format: str = "audio/pcm;rate=24000"
    is_complete: bool = False        # fin de turno
    is_interrupted: bool = False     # barge-in (el usuario cortó)
    tool_calls: List[LiveToolCall] = []
    usage: Optional[LiveCompletionUsage] = None
    turn_metadata: Optional[VoiceTurnMetadata] = None
    session_id / turn_id / user_id
    metadata: Dict[str, Any] = {}    # user_transcription, display_data, go_away...

El handler traduce cada LiveVoiceResponse a uno o varios mensajes WebSocket (response_chunk, transcription, tool_call, display_data, response_complete).

1.4 VAD, turn-taking e interrupción (lo importante para la UX)

GeminiLiveClient configura detección automática de actividad de voz (VAD):

realtime_input_config=types.RealtimeInputConfig(
    automatic_activity_detection=types.AutomaticActivityDetection(
        disabled=False,
        start_of_speech_sensitivity=START_SENSITIVITY_HIGH,
        end_of_speech_sensitivity=END_SENSITIVITY_HIGH,
        prefix_padding_ms=100,
        silence_duration_ms=500,   # 500 ms de silencio cierra el turno
    )
)

Implicaciones para el frontend:

  • No necesitas botón push-to-talk en modo streaming: basta con enviar audio continuamente; Gemini detecta inicio/fin de turno. (El push-to-talk sigue siendo válido y útil en modo buffered.)
  • Barge-in: si el usuario habla mientras el asistente responde, Gemini emite is_interrupted=True. La UI debe vaciar inmediatamente su cola de reproducción de audio al recibir response_chunk.is_interrupted == true o response_complete.is_interrupted == true.
  • Fin de turno: response_complete + ready_to_speak señalan que el asistente terminó.

2. VoiceChatHandler — el endpoint WebSocket

Archivo: packages/ai-parrot-integrations/src/parrot/voice/handler.py

Es el handler aiohttp que el navegador consume. (Existe también voice/server.py, un servidor standalone alternativo; para integrar en una app aiohttp usa VoiceChatHandler.)

2.1 Montaje en el servidor

from aiohttp import web
from parrot.voice.handler import VoiceChatHandler

handler = VoiceChatHandler(
    bot_factory=lambda: create_voice_bot(name="Sales", voice_name="Puck", tools=[...]),
    require_auth=True,
    secret_key=os.environ["JWT_SECRET"],   # o un TokenValidator custom
    auth_timeout=30.0,
    ws_route="/ws/voice",
)

app = web.Application()
handler.setup_routes(app, prefix="/api/v1")   # → /api/v1/ws/voice (+ /health, /static)
web.run_app(app, host="0.0.0.0", port=8765)

Rutas registradas por setup_routes(app, prefix, include_health=True, include_static=True):

Ruta Método Propósito
{prefix}/ws/voice GET (upgrade) WebSocket de voz (principal)
{prefix}/health GET health check
{prefix}/static/* GET servir assets (opcional)

2.2 Autenticación (3 métodos)

El handler valida un JWT. Hay tres formas de pasarlo; elige una:

Método Cómo Cuándo
Sec-WebSocket-Protocol (recomendado) new WebSocket(url, ["jwt", token]) más seguro: se valida antes del upgrade
Query param new WebSocket(url + "?token=" + token) simple, pero el token queda en logs
Mensaje post-conexión ws.send({type:"auth", token}) cuando el token no está disponible al conectar

TokenValidator (packages/ai-parrot/src/parrot/core/ws_auth.py) resuelve en orden: validator_func custom → navigator_auth.SECRET_KEYsecret_key fallback → modo fallback (acepta cualquier token; solo testing). Devuelve un AuthenticatedUser (user_id, username, email, roles, permissions).

Si require_auth=False, puedes omitir todo lo de auth y empezar la sesión directamente.

2.3 Ciclo de vida de la sesión

connect ──▶ {connected}
   │  (auth si aplica) ──▶ {auth_success | auth_error | auth_required}
start_session ──▶ {session_started} ──▶ {ready_to_speak}
   ├─ (streaming) enviar audio binario/base64 en continuo
   │     ◀── response_chunk* ◀── transcription ◀── tool_call ◀── response_complete ◀── ready_to_speak
   └─ (buffered) start_recording → audio... → stop_recording (o voice_complete)
         ◀── processing ◀── voice_response ◀── ready_to_speak
end_session ──▶ {session_ended}   (o reset_session para reiniciar)
disconnect

2.4 Modos: streaming vs buffered

Se elige en start_session con streaming_mode (default "streaming").

Aspecto streaming (recomendado, full-interactive) buffered (push-to-talk)
Entrada de audio frames enviados en continuo → cola → Gemini al instante se acumulan y se mandan de golpe
Disparo de proceso continuo (VAD detecta turnos) stop_recording o voice_complete
Respuesta troceada (response_chunk) en tiempo real completa (voice_response)
Latencia baja mayor
Caso de uso conversación natural, barge-in móvil, grabación offline

3. Protocolo WebSocket completo

Verificado contra _handle_message y los handlers en handler.py.

3.1 Cliente → Servidor

type Payload Efecto
auth {token} o {authorization:"Bearer <jwt>"} autenticación post-conexión
ping {timestamp?} keepalive (responde pong)
start_session {config:{...}, streaming_mode?, avatar?, avatar_id?, tenant_id?} inicia sesión de voz
end_session {} termina sesión
reset_session {} end + restart
start_recording {} marca inicio de grabación (buffered)
stop_recording {} fin de grabación → procesa buffer
audio_data / audio_chunk {data:"<base64 PCM16 16k>"} chunk de audio vía JSON
send_text / text_message {text:"...", streaming?} entrada de texto → respuesta hablada
voice_complete / voice_buffer {audio_base64:"..."} buffer completo (no streaming)
(binario) bytes crudos PCM16 16k forma más eficiente de mandar audio

config de start_session:

{
  "voice_name": "Puck",        // opcional
  "language": "en-US",         // opcional
  "system_prompt": "...",      // opcional, override del prompt
  "model": "gemini-2.5-...",   // opcional
  "send_welcome": false         // opcional, saludo inicial
}

Campos de avatar (FEAT-245, aditivos): avatar: true, avatar_id?, tenant_id? (ver §4).

3.2 Servidor → Cliente

type Payload Cuándo
connected {session_id, authenticated, require_auth} al conectar
auth_success {user:{user_id, username}} auth OK
auth_error {message} auth falló
auth_required {message} el server exige auth y no llegó
session_started {session_id, user_id, streaming_mode, config:{...}, avatar?} sesión lista
ready_to_speak {message} listo para nueva entrada (tras session_started y tras cada turno)
recording_started {} grabación iniciada (buffered)
recording_stopped {message, duration_ms?} grabación terminada
processing {message, audio_size?} procesando audio (buffered)
response_chunk {text, audio_base64, audio_format, is_interrupted} chunk de respuesta (streaming)
voice_response {text, audio_base64, audio_format, is_complete, usage?} respuesta completa (buffered)
response_complete {text, is_interrupted} fin de turno (streaming)
transcription {text, is_user} STT de usuario (y/o asistente)
tool_call {name, arguments, result, execution_time_ms} herramienta ejecutada
display_data {data} metadata visual de una tool (gráficos, tablas...)
pong {timestamp, ping_count, session_id, session_active, ...} respuesta a ping
session_ended {session_id} sesión terminada
session_warning {message, time_left?} Gemini va a reconectar (GoAway)
error {message, code?, is_retryable?} error

Formatos de audio (en session_started.config):

input_format:  "audio/pcm;rate=16000"   // lo que el navegador envía
output_format: "audio/pcm;rate=24000"   // lo que el servidor devuelve

⚠️ response_chunk vs voice_response: en streaming el audio llega troceado en response_chunk y el turno cierra con response_complete. En buffered llega una sola voice_response con is_complete:true. Tu UI debe manejar ambos según el modo elegido.

🧠 El handler filtra "pensamientos" del modelo (texto tipo **Clarifying...** o **Show Product...**) y no reenvía la transcripción del asistente para evitar duplicar el texto de response_chunk. Es decir: el texto del asistente lo obtienes del text de los chunks, no de un evento transcription is_user:false.


4. FEAT-245 — Avatar con lip-sync (LiveAvatar + LiveKit)

Spec: sdd/specs/voicechat-liveavatar-gemini.spec.md · Estado: completo y mergeado (TASK-1588/1589/1590 ✅).

4.1 Qué hace

Gemini Live emite audio a 24 kHz mono 16-bit — exactamente lo que espera la "boca" del avatar (AVATAR_PCM_SAMPLE_RATE = 24_000). FEAT-245 tee-a (duplica) ese mismo audio: una copia va al navegador (comportamiento existente) y otra va al avatar, sin resampling.

Gemini Live (24k) ──▶ VoiceChatHandler._send_voice_response
                          ├──▶ response_chunk (browser, audio b64)         [existente]
                          └──▶ VoiceAvatarSession.speak(pcm_24k)            [FEAT-245]
                                 └──▶ AvatarWebSocket.send_audio_frame → LiveAvatar
                                        └──▶ LiveKit room ──▶ Browser (vídeo+audio avatar)

Componentes nuevos (packages/ai-parrot-integrations/src/parrot/integrations/liveavatar/):

  • VoiceAvatarSession (voice_session.py) — wrapper de ciclo de vida: arranca la sesión LiveAvatar, mintea tokens LiveKit, expone viewer_credentials, speak(), finish_turn(), interrupt(), aclose().
  • AvatarWebSocket (avatar_ws.py) — transporte WS al media server (protocolo LITE): trocea el PCM (primer chunk ~400 ms, resto ~1 s, tope 1 MB) y emite frames {"type":"agent.speak","audio":"<b64>"}, agent.speak_end, agent.interrupt.
  • LiveKitRoomManager (room_manager.py) — mintea client_token (subscribe-only, para el navegador) y agent_token (publish, solo servidor).
  • is_avatar_enabled (optin.py) — gate de opt-in por tenant/agente (default-deny).

4.2 Cómo lo activa el frontend

En start_session, añade los campos de avatar:

{
  "type": "start_session",
  "streaming_mode": "streaming",
  "config": { "voice_name": "Puck", "language": "en-US" },
  "avatar": true,
  "avatar_id": "5761a14c",      // opcional: override del avatar
  "tenant_id": "acme"            // requerido para el gate de opt-in
}

La respuesta session_started incluye un bloque avatar:

// Caso OK (tenant habilitado, sesión arrancó):
{
  "type": "session_started",
  "session_id": "sess-abc",
  "config": { "voice_name": "Puck", "input_format": "audio/pcm;rate=16000",
              "output_format": "audio/pcm;rate=24000" },
  "avatar": {
    "active": true,
    "livekit_url": "wss://<project>.livekit.cloud",
    "client_token": "<JWT subscribe-only>",   // ← úsalo para unirte a la sala
    "room": "sess-abc",                        // = session_id
    "audio": "dual"                            // ← señal: silencia una fuente de audio
  }
}

// Caso degradado (no habilitado, error, o extra no instalado):
{ "type": "session_started", ...,
  "avatar": { "active": false, "reason": "avatar mode is not enabled for this tenant" } }

4.3 Reglas críticas del avatar para el frontend

  1. Degradación elegante: si avatar.active == false, la voz funciona igual; muestra solo el modo voz-sin-cara. Nunca bloquees la conversación porque el avatar falle.
  2. Audio dual / eco: cuando avatar.active == true y avatar.audio == "dual", el audio llega por /ws/voice y por la sala LiveKit. Silencia el audio de /ws/voice y deja sonar solo la pista del avatar (lleva el lip-sync sincronizado). Si no, habrá eco/doble voz.
  3. Solo subscribe: el client_token es subscribe-only. El navegador se une a la sala LiveKit como espectador (vídeo+audio del avatar). El micrófono del usuario NO se publica a LiveKit; sigue yendo por /ws/voice.
  4. Barge-in: cuando llega response_*.is_interrupted == true, el servidor ya llama a avatar.interrupt(); tu UI solo debe vaciar su cola de audio local.
  5. Estructurados (charts/tablas/tool_call) no viajan por LiveKit: llegan por /ws/voice (tool_call, display_data). Renderízalos en tu panel de chat como siempre.

4.4 Unirse a la sala LiveKit (cliente)

import { Room, RoomEvent } from 'livekit-client';

async function joinAvatarRoom(avatar) {
  if (!avatar?.active) return null;
  const room = new Room({ adaptiveStream: true });
  await room.connect(avatar.livekit_url, avatar.client_token);

  room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
    if (track.kind === 'video') {
      track.attach(document.getElementById('avatarVideo'));
    } else if (track.kind === 'audio') {
      track.attach();                  // reproduce la voz del avatar (con lip-sync)
      muteVoiceWsPlayback(true);       // ← silencia la fuente /ws/voice (evita eco)
    }
  });
  return room;
}

5. Implementación del frontend — receta completa

5.1 Captura de micrófono → PCM16 16 kHz

El navegador da audio float32 a la sampleRate del AudioContext. Hay que downsamplear a 16 kHz y convertir a PCM 16-bit little-endian. Usa un AudioWorklet (no ScriptProcessorNode, deprecado).

// pcm-worklet.js  (AudioWorkletProcessor)
class PCMWorklet extends AudioWorkletProcessor {
  constructor() { super(); this._buf = []; }
  process(inputs) {
    const ch = inputs[0][0];
    if (ch) this.port.postMessage(ch.slice(0));   // float32 @ contextRate
    return true;
  }
}
registerProcessor('pcm-worklet', PCMWorklet);
// main.js — captura + resample a 16k + PCM16
async function startMic(ws) {
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }
  });
  const ctx = new AudioContext();                 // p.ej. 48000 Hz
  await ctx.audioWorklet.addModule('pcm-worklet.js');
  const src = ctx.createMediaStreamSource(stream);
  const node = new AudioWorkletNode(ctx, 'pcm-worklet');

  node.port.onmessage = (e) => {
    const pcm16 = floatTo16kPCM(e.data, ctx.sampleRate);  // ↓ ver helper
    if (ws.readyState === WebSocket.OPEN) ws.send(pcm16); // ← BINARIO, lo más eficiente
  };
  src.connect(node);
  return { ctx, stream };
}

// downsample lineal a 16 kHz + Int16
function floatTo16kPCM(float32, inRate) {
  const ratio = inRate / 16000;
  const outLen = Math.floor(float32.length / ratio);
  const out = new Int16Array(outLen);
  for (let i = 0; i < outLen; i++) {
    const s = Math.max(-1, Math.min(1, float32[Math.floor(i * ratio)]));
    out[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
  }
  return out.buffer;  // ArrayBuffer PCM16 16k mono LE
}

5.2 Reproducción del audio 24 kHz del servidor

response_chunk.audio_base64 es PCM16 24k mono. Decodifícalo y encólalo en un AudioContext a 24 kHz para reproducción continua sin cortes.

class PCMPlayer {
  constructor(sampleRate = 24000) {
    this.ctx = new AudioContext({ sampleRate });
    this.nextTime = 0;
    this.sources = [];
  }
  enqueueBase64Pcm16(b64) {
    const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
    const i16 = new Int16Array(bytes.buffer);
    const f32 = Float32Array.from(i16, v => v / 0x8000);
    const buf = this.ctx.createBuffer(1, f32.length, this.ctx.sampleRate);
    buf.copyToChannel(f32, 0);
    const node = this.ctx.createBufferSource();
    node.buffer = buf; node.connect(this.ctx.destination);
    const t = Math.max(this.ctx.currentTime, this.nextTime);
    node.start(t);
    this.nextTime = t + buf.duration;
    this.sources.push(node);
  }
  flush() {                       // barge-in: cortar reproducción inmediatamente
    this.sources.forEach(s => { try { s.stop(); } catch {} });
    this.sources = [];
    this.nextTime = 0;
  }
}

5.3 Cliente WebSocket de referencia

El repo ya incluye un cliente JS de ejemplo y dos páginas HTML completas:

  • packages/ai-parrot-integrations/src/parrot/voice/ui/basic.js — clase VoiceChatClient.
  • packages/ai-parrot-integrations/src/parrot/voice/ui/voice_chat.html — UI de voz completa.
  • packages/ai-parrot-integrations/src/parrot/voice/ui/chat.html — UI de chat.

Esqueleto mínimo full-interactive (streaming + avatar opcional):

const player = new PCMPlayer(24000);
let avatarRoom = null;

const ws = new WebSocket('wss://api.example.com/api/v1/ws/voice', ['jwt', JWT]);
ws.binaryType = 'arraybuffer';

ws.onmessage = async (ev) => {
  if (ev.data instanceof ArrayBuffer) return;          // (este server manda audio en JSON b64)
  const m = JSON.parse(ev.data);
  switch (m.type) {
    case 'connected':
      ws.send(JSON.stringify({
        type: 'start_session',
        streaming_mode: 'streaming',
        config: { voice_name: 'Puck', language: 'en-US' },
        avatar: USE_AVATAR, tenant_id: TENANT,
      }));
      break;

    case 'session_started':
      if (m.avatar?.active) {
        avatarRoom = await joinAvatarRoom(m.avatar);   // silencia /ws/voice dentro
      }
      break;

    case 'ready_to_speak':
      await startMic(ws);                              // empieza a enviar PCM16 16k binario
      break;

    case 'response_chunk':
      if (m.is_interrupted) player.flush();            // barge-in
      else if (m.audio_base64 && !avatarRoom) player.enqueueBase64Pcm16(m.audio_base64);
      if (m.text) appendAssistantText(m.text);
      break;

    case 'response_complete':
      if (m.is_interrupted) player.flush();
      finalizeAssistantTurn(m.text);
      break;

    case 'transcription':
      if (m.is_user) showUserBubble(m.text);
      break;

    case 'tool_call':    renderToolCall(m); break;     // nombre, args, result
    case 'display_data': renderArtifact(m.data); break;// charts/tablas
    case 'session_warning': /* Gemini reconectará */ break;
    case 'error':        showError(m.message); break;
  }
};

// keepalive
setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30000);

5.4 Checklist de estados de UI

Estado UI Disparado por Acción visual
Conectando new WebSocket spinner
Conectado / autenticado connected / auth_success habilitar "Start"
Sesión lista session_started + ready_to_speak habilitar micrófono
Avatar activo session_started.avatar.active mostrar <video>, silenciar audio /ws/voice
Usuario hablando (VAD; opcional transcription is_user) onda/indicador "escuchando"
Asistente hablando response_chunk con audio onda "hablando" + reproducir/lip-sync
Interrumpido *.is_interrupted player.flush(), cortar onda
Turno completo response_complete + ready_to_speak volver a "listo"
Herramienta tool_call / display_data tarjeta de tool / artefacto
Reconexión session_warning aviso discreto, no cortar
Error error toast; reintentar si is_retryable

6. Variables de entorno (servidor)

# Auth
JWT_SECRET=<secret>                       # o navigator_auth.SECRET_KEY

# Gemini Live (VoiceBot)
GOOGLE_API_KEY=<key>                      # o VertexAI (project/location/credentials)

# FEAT-245 — LiveAvatar
LIVEAVATAR_API_KEY=<secret>
LIVEAVATAR_AVATAR_ID=<avatar_id>
LIVEAVATAR_BASE_URL=https://api.liveavatar.com
LIVEAVATAR_SANDBOX=false                  # ⚠️ avatar de producción exige false (si no, /start 400)
LIVEAVATAR_WS_CONNECT_TIMEOUT=5.0

# FEAT-245 — LiveKit Cloud
LIVEKIT_URL=wss://<project>.livekit.cloud
LIVEKIT_API_KEY=<key>
LIVEKIT_API_SECRET=<secret>

# FEAT-245 — opt-in gate (default-deny)
LIVEAVATAR_ENABLED_TENANTS=acme,demo      # o "*" para todos
LIVEAVATAR_ENABLED_AGENTS=agent1,agent2   # opcional, filtra por agente

El extra de avatar es opcional: pip install ai-parrot-integrations[liveavatar]. Si no está instalado y pides avatar:true, el handler devuelve avatar.active=false (import perezoso, degradación elegante) y /ws/voice funciona igual.


7. Errores frecuentes y cómo evitarlos

Síntoma Causa Solución
Audio entrecortado / "chipmunk" enviar al sample rate del AudioContext (48k) en vez de 16k downsamplear a 16 kHz antes de enviar
El asistente no responde enviar mono≠1, formato no-PCM, o no llegar a ready_to_speak mono, PCM16 16k, esperar ready_to_speak
Eco / voz doble con avatar reproduces audio de /ws/voice y de LiveKit silencia /ws/voice cuando avatar.audio=="dual"
El asistente no se calla al interrumpir ignoras is_interrupted player.flush() al recibirlo
Avatar no aparece avatar.active=false revisar tenant_id en opt-in + LIVEAVATAR_SANDBOX + extra instalado
/start da 400 avatar de producción con SANDBOX=true LIVEAVATAR_SANDBOX=false
Texto del asistente duplicado esperabas transcription is_user:false el texto viene en response_chunk.text; el server suprime esa transcripción
Conexión se cae a los minutos sin keepalive enviar ping cada ~30 s

8. Referencias de código (rutas absolutas)

VoiceBot / cliente Gemini Live - packages/ai-parrot/src/parrot/bots/voice.pyVoiceBot - packages/ai-parrot/src/parrot/models/voice.pyVoiceConfig - packages/ai-parrot/src/parrot/clients/live.pyGeminiLiveClient, LiveVoiceResponse - packages/ai-parrot/src/parrot/core/ws_auth.pyTokenValidator, AuthenticatedUser

Handler WebSocket - packages/ai-parrot-integrations/src/parrot/voice/handler.pyVoiceChatHandler, WebSocketConnection - packages/ai-parrot-integrations/src/parrot/voice/ui/basic.js — cliente JS de referencia - packages/ai-parrot-integrations/src/parrot/voice/ui/voice_chat.html — UI de voz completa

FEAT-245 — Avatar - sdd/specs/voicechat-liveavatar-gemini.spec.md — spec - sdd/tasks/index/voicechat-liveavatar-gemini.json — tareas (todas done) - .../integrations/liveavatar/voice_session.pyVoiceAvatarSession - .../integrations/liveavatar/avatar_ws.pyAvatarWebSocket (protocolo LITE) - .../integrations/liveavatar/room_manager.pyLiveKitRoomManager (tokens) - .../integrations/liveavatar/optin.pyis_avatar_enabled - .../integrations/liveavatar/models.py — modelos Pydantic - packages/ai-parrot-integrations/tests/voice/test_voicechat_avatar_integration.py — tests e2e

Guías de frontend relacionadas - docs/frontend/liveavatar-frontend-guide.md — Fase A (FEAT-242) - docs/frontend/liveavatar-phase-c-frontend-guide.md — Fase C (FEAT-243, eliminada en FEAT-249) - docs/frontend/agentalk-voice-frontend-guide.md — voz AgentTalk - docs/frontend/structured-artifacts-frontend-guide.md — artefactos estructurados

Propuesta relacionada (sin commitear) - sdd/proposals/spec-liveavatar-fullmode-speaktext.md — FULL Mode (avatar.speak_text), alternativa a FEAT-245 donde el avatar habla por texto en vez de por PCM crudo.