← Todo el material
Curso Avanzado 24 de agosto de 2026

Voz e IA — Módulo 6: streaming — ASR y TTS en tiempo real

Sexto módulo del Curso de Voz e IA en tiempo real: cómo se procesa el audio mientras llega en vez de esperar el archivo completo. Transcripción parcial e incremental, síntesis por streaming, el chunkeo del audio, WebSocket y WebRTC como transporte, y el manejo de interrupciones (barge-in).

☰ Índice del contenido

En el módulo 5 desarmamos la latencia de una conversación por voz y llegamos a una conclusión incómoda: si cada etapa del pipeline espera a que la anterior termine del todo, los tiempos se suman y la conversación se siente como un walkie-talkie. La palanca principal para romper esa suma era el streaming (procesamiento en flujo: trabajar el audio a medida que llega, no cuando terminó de llegar). En ese módulo lo nombramos como la gran idea; en este lo implementamos de verdad. Vamos a ver cómo cada etapa deja de recibir un archivo completo y empieza a masticar pedazos, qué gana con eso, y qué problemas nuevos aparecen — porque el streaming no es gratis: trae transcripciones que cambian bajo tus pies, frases que se cortan a la mitad e interrupciones que hay que atender en caliente.

La tesis del módulo

El streaming convierte cada etapa de “recibe todo, procesa, devuelve todo” en “procesa el pedazo que ya llegó”. Ese cambio de granularidad es lo que hace posible una conversación en lugar de un intercambio de mensajes de voz. Pero cambiar la granularidad trae sus propios problemas: resultados que cambian mientras se emiten, frases que se cortan e interrupciones del usuario en mitad de la respuesta. Dominar el streaming es dominar esos tres problemas, no solo abrir un socket.

Del archivo al flujo: qué cambia exactamente

En un pipeline por lotes (batch), cada etapa es una función: recibe su entrada completa, la procesa y devuelve su salida completa. El ASR (Automatic Speech Recognition, reconocimiento automático del habla: pasar audio a texto) espera a que termines de hablar, transcribe todo de una vez y pasa el texto al modelo. Simple de programar, imposible de conversar: la latencia de cada etapa se apila.

En un pipeline por flujo, cada etapa es un generador: consume su entrada de a pedazos y emite su salida de a pedazos, sin esperar a tener todo. El ASR recibe chunks (fragmentos: trozos cortos de audio, típicamente de 20 a 250 ms) y va emitiendo texto a medida que reconoce. El modelo empieza a “leer” ese texto antes de que termines la frase. El TTS (Text-to-Speech, síntesis de voz: pasar texto a audio) empieza a sonar antes de que el modelo termine de escribir. Todo se solapa. Ese solapamiento es la conversación.

     BATCH (las etapas se suman)

 habla ──► [ASR] ──► [LLM] ──► [TTS] ──► oye
           400ms    800ms     300ms
           \___________________________/
                1500 ms en serie


     STREAMING (las etapas se solapan)

 habla ──► [ASR] ──► [LLM] ──► [TTS] ──► oye
           ░░░░░░░░░░░░░░
                 ░░░░░░░░░░░░░░
                       ░░░░░░░░░░░░░░
           \___________________________/
                primer audio mucho antes

El módulo 5 mostró por qué esto importa para la latencia. Acá importa por qué es difícil.

ASR por streaming: resultados que cambian

El primer problema del streaming aparece en el ASR. Cuando transcribes de a pedazos, no siempre tienes suficiente contexto para estar seguro. Escuchas “vein…” y no sabes si viene “veinte”, “veinticinco” o “Venecia”. El motor tiene dos opciones: callarse hasta estar seguro (y llegar tarde) o arriesgar una hipótesis y corregirla después. Los ASR de streaming eligen la segunda, y por eso emiten dos tipos de resultado:

  • Resultados parciales (partial o interim results, resultados provisionales): hipótesis tempranas que el motor puede reescribir cuando llega más audio. Se emiten con una marca de no-definitivo. Son los que ves “temblar” en la pantalla de un dictado: la palabra aparece, cambia, se corrige.
  • Resultado final (final result): el texto de ese segmento ya consolidado, que no va a cambiar. Llega con algo de retraso respecto al parcial, pero es en el que puedes confiar para actuar.

La tensión es directa: estabilidad contra latencia. Si esperas siempre el final, la transcripción no tiembla pero llegas tarde. Si actúas sobre parciales, respondes antes pero puedes actuar sobre una palabra que estaba mal reconocida y que el motor iba a corregir un instante después.

Cómo se ve la API

Los servicios de ASR en flujo abren un WebSocket (canal bidireccional persistente; lo vemos en detalle más abajo), tú le mandas chunks de audio, y él te devuelve mensajes JSON con la transcripción. En Deepgram, por ejemplo, cada mensaje trae una marca is_final: los parciales llegan con is_final: false y pueden reescribirse; cuando el motor consolida un segmento, lo reenvía con is_final: true. Aparte, con el endpointing activado (detección de fin de habla por silencio) manda una señal speech_final: true cuando detecta que dejaste de hablar — esa señal es la que usas para cerrar el turno y disparar al modelo. La receta oficial es acumular los is_final: true en un buffer y, al llegar speech_final: true, tomar el buffer como el enunciado completo. Otros proveedores (AssemblyAI, la API en tiempo real de OpenAI, Whisper en modo streaming vía wrappers) exponen la misma idea con nombres distintos: siempre hay un evento “provisional” y uno “consolidado”.

Ojo: no dispares acciones con efecto (llamar una tool, cobrar, responder) sobre un resultado parcial. Un parcial es una apuesta, no un hecho. Muéstralo en pantalla si quieres feedback visual, pero para actuar espera el final. Actuar sobre parciales es una de las causas más comunes de agentes de voz que “entienden mal” cosas que el usuario dijo bien.

TTS por streaming: sonar antes de terminar de sintetizar

El mismo principio, al revés, en la salida. En batch, el TTS recibe la frase entera, sintetiza todo el audio y recién entonces empieza a sonar. Eso mete el largo completo de la síntesis en la latencia de respuesta. En streaming, el TTS empieza a reproducir el audio de las primeras palabras mientras todavía sintetiza el resto.

Y hay una segunda capa de streaming que multiplica la ganancia: no le pases al TTS la respuesta completa del modelo, pásasela a medida que el modelo la emite. El modelo genera texto token a token; tú vas armando cláusulas u oraciones y las mandas al TTS apenas están completas. Así el audio empieza a sonar cuando el modelo lleva media frase escrita, no cuando terminó. Servicios como ElevenLabs exponen esto con un WebSocket de entrada incremental: le mandas el texto por pedazos y él va devolviendo audio, decidiendo internamente cuándo tiene suficiente para comprometer una porción de síntesis con buena prosodia (su auto_mode maneja ese punto de corte automáticamente).

El detalle fino es por dónde cortar. Si mandas al TTS cada token suelto, la prosodia (entonación) se rompe: el sintetizador no sabe si “veinte” cierra la frase o sigue. Si esperas la frase entera, pierdes la ganancia de latencia. El punto medio práctico es cortar por cláusulas u oraciones: acumular tokens hasta un signo de puntuación fuerte (., ?, !, ;, a veces ,) y recién ahí despachar el fragmento. Suficiente contexto para que suene natural, suficiente rapidez para que no espere el final.

Cuidado: el streaming de TTS necesita un buffer de reproducción (una cola de audio ya sintetizado esperando a sonar). Si reproduces cada chunk apenas llega, cualquier microdemora de red deja un hueco de silencio en medio de una palabra y suena horrible. La regla: acumula un colchón pequeño (100–200 ms) antes de empezar a sonar, y sigue rellenándolo mientras reproduces. Buffer de más agrega latencia; buffer de menos mete cortes. Es el mismo compromiso estabilidad/latencia del ASR, ahora en la salida.

Chunking y ventanas: el arte de cortar el audio

Todo el streaming se apoya en cortar el audio en chunks, y el tamaño del chunk es una decisión de diseño con tres tensiones a la vez:

Chunk pequeño (20–40 ms)Chunk grande (200–500 ms)
Menos latencia: reaccionas antesMás latencia: esperas a llenar el chunk
Peor precisión: menos contexto por trozoMejor precisión: más contexto acústico
Más mensajes, más overhead de redMenos overhead, menos mensajes
Bueno para detectar que el usuario empezó a hablarBueno para transcribir con calidad

No hay un tamaño correcto universal: depende de para qué es ese chunk. Para detectar que el usuario empezó a hablar (y así permitir la interrupción) quieres chunks chicos, porque necesitas reaccionar rápido. Para transcribir con calidad el motor internamente prefiere ventanas más grandes. Por eso muchos motores trabajan con ventanas solapadas (overlapping windows): procesan bloques que comparten audio con el bloque anterior, para no perder información justo en el corte.

Y ahí está la trampa clásica: cortar una palabra a la mitad. Si el borde del chunk cae en medio de “vein-|-ticinco”, cada mitad por separado no significa nada. El solapamiento existe precisamente para esto: como los bloques comparten sus bordes, la palabra partida aparece completa en al menos uno de ellos. Es responsabilidad del motor, no tuya — pero explica por qué no puedes tratar cada chunk como una transcripción independiente y concatenar: tienes que dejar que el motor reconcilie los bordes, que es justo lo que hace el mecanismo de parcial/final.

Transporte: por qué HTTP no sirve, y qué usar

El modelo request/response de HTTP (mandas una petición, esperas, recibes una respuesta, se cierra) es incompatible con una conversación. Fue diseñado para pedir una página, no para un flujo continuo en las dos direcciones a la vez. En voz necesitas mandar audio y recibir audio simultáneamente y sin cerrar la conexión. Eso descarta HTTP clásico y deja dos opciones reales.

  • WebSocket: un canal full-duplex (bidireccional simultáneo: ambos lados pueden emitir a la vez) sobre una conexión persistente. Simple de programar, corre sobre TCP, funciona en cualquier navegador y servidor. Es la opción por defecto para conectar tu backend con las APIs de ASR y TTS, y para prototipos.
  • WebRTC (Web Real-Time Communication): un stack completo diseñado específicamente para audio y video en vivo. Corre sobre UDP, trae jitter buffer (amortigua la llegada irregular de paquetes), cancelación de eco y supresión de ruido integradas antes de que el audio entre a la red, control de congestión, y NAT traversal (mecanismo para conectar dos equipos detrás de routers domésticos). Más complejo de montar, pero es lo que la industria usa para el tramo navegador-a-agente.

La diferencia práctica no es “cuál tiene menos latencia en red buena” — en buenas condiciones los dos dan menos de 50 ms. La diferencia aparece cuando la red se degrada y cuando importa la calidad del audio de entrada:

WebSocketWebRTC
Diseñado paraDatos genéricos bidireccionalesAudio/video en vivo
TransporteTCPUDP (SRTP)
Ante pérdida de paquetesSe atasca (head-of-line blocking)Tolera con FEC/PLC, sigue inteligible
Jitter bufferNo (lo montas tú)Sí, integrado
Eco / ruido / gananciaNoAEC, supresión y AGC integrados
NAT traversalNo lo necesita (TCP saliente)Sí (ICE/STUN/TURN)
Complejidad de montajeBajaAlta
Uso típicoBackend↔API, prototiposNavegador↔agente en producción

La regla operativa que sale de esto: WebSocket para el tramo servidor-a-servidor y para empezar; WebRTC para el tramo con el usuario real en producción, sobre todo si es teléfono o red móvil, donde la pérdida de paquetes y el ruido ambiente son la norma. Un patrón muy común en 2026 es híbrido: WebRTC entre el usuario y tu infraestructura, WebSocket entre tu infraestructura y las APIs de modelos.

Barge-in: cuando el usuario interrumpe

Acá está el problema más específico del tiempo real, y el que más separa un agente que se siente vivo de uno que se siente un contestador. Barge-in (irrupción: el usuario empieza a hablar mientras el agente todavía está hablando). En una conversación humana esto pasa todo el tiempo — interrumpes para corregir, para decir “no, espera”, para responder antes de que el otro termine. Un agente que ignora la interrupción y sigue soltando su párrafo se siente sordo y maleducado.

Manejar barge-in son tres acciones que tienen que pasar rápido y en orden:

  1. Detectar que el usuario empezó a hablar mientras el TTS sonaba. Lo hace un VAD (Voice Activity Detection, detección de actividad de voz: distinguir voz de silencio o ruido) corriendo sobre el micrófono en paralelo a la reproducción. Este es el detalle que se profundiza en el próximo módulo.
  2. Cortar el TTS de inmediato: dejar de reproducir y vaciar el buffer de audio ya sintetizado que estaba en cola. Si no vacías el buffer, el agente sigue sonando dos segundos después de que “se calló”, porque había audio esperando.
  3. Descartar la respuesta en curso: cancelar la generación del modelo que estaba produciendo esa respuesta (en la API en tiempo real de OpenAI, esto es un response.cancel) y actualizar el estado de la conversación para que el modelo sepa que no terminó de decir lo que estaba diciendo. Si no haces esto, el modelo cree que ya dijo todo y la próxima respuesta parte de un contexto falso.

Cuidado: el enemigo del barge-in es el falso positivo. Un “ajá”, una tos o un ruido de fondo no deberían cortar al agente. Por eso los sistemas serios usan VAD semántico (que espera a estimar que el usuario de verdad quiso tomar la palabra) en vez de solo un umbral de silencio: un VAD de puro volumen corta al agente cada vez que el usuario respira. El equilibrio entre “corta rápido” y “no corta por cualquier ruido” es el corazón del turn-taking, y es el tema completo del módulo 7.

Código: el loop de streaming full-duplex

Este es el esqueleto de un loop de conversación por voz. A alto nivel y comentado — cada proveedor cambia las firmas exactas, pero la estructura es esta en todos:

"""Loop de streaming full-duplex de un agente de voz.

Tres tareas corren EN PARALELO sobre la misma conexión:
recibir audio del micrófono, transcribir de forma incremental,
y sintetizar/reproducir la respuesta. El barge-in vive en el cruce
entre 'llega audio del usuario' y 'el TTS está sonando'.
"""
import asyncio

async def loop_de_voz(cliente_ws, asr, llm, tts):
    buffer_final = []          # trozos ya consolidados del turno
    tts_activo = None          # tarea de TTS en curso, si la hay

    async for evento in asr.stream(cliente_ws.audio_entrante()):
        # --- BARGE-IN: el usuario habla mientras el agente habla ---
        if evento.tipo == "habla_iniciada" and tts_activo:
            tts_activo.cancel()          # 1) corta la síntesis
            await cliente_ws.vaciar_buffer_audio()   # 2) descarta lo encolado
            await llm.cancelar_respuesta()           # 3) descarta lo en curso
            tts_activo = None

        # --- ASR incremental ---
        if evento.tipo == "parcial":
            # solo feedback visual: NUNCA actuar sobre un parcial
            await cliente_ws.mostrar_provisional(evento.texto)

        elif evento.tipo == "final":
            # segmento consolidado: se puede confiar en él
            buffer_final.append(evento.texto)

        elif evento.tipo == "fin_de_turno":
            # el usuario dejó de hablar: cerramos y respondemos
            pregunta = " ".join(buffer_final)
            buffer_final.clear()
            tts_activo = asyncio.create_task(
                responder(pregunta, llm, tts, cliente_ws))


async def responder(pregunta, llm, tts, cliente_ws):
    """Streaming de punta a punta en la salida: el LLM emite tokens,
    se agrupan por cláusula, se sintetizan y se reproducen ANTES de
    que el LLM termine de escribir la respuesta completa."""
    clausula = ""
    async for token in llm.stream(pregunta):
        clausula += token
        if token.rstrip().endswith((".", "?", "!", ";", ",")):
            # cláusula completa: al TTS ya, sin esperar el resto
            async for audio in tts.sintetizar_stream(clausula):
                await cliente_ws.reproducir(audio)   # con su buffer
            clausula = ""
    if clausula.strip():                    # cola final sin puntuación
        async for audio in tts.sintetizar_stream(clausula):
            await cliente_ws.reproducir(audio)

Fíjate en dónde vive cada idea del módulo: el barge-in es el primer if porque tiene que ganarle a todo lo demás; el ASR distingue parcial de final; y la salida corta por puntuación para no romper la prosodia. Ninguna etapa espera a la anterior.

Diagrama: el pipeline full-duplex con barge-in

              PIPELINE DE VOZ EN STREAMING

 micrófono
    │ audio en vivo
    ▼
 ┌──────────┐  chunks   ┌──────────────┐
 │ chunking │ ────────► │  ASR stream  │
 └──────────┘           └──────┬───────┘
    ▲                          │ parcial (solo pantalla)
    │ el usuario                │ final + fin_de_turno
    │ vuelve a hablar           ▼
    │ (barge-in)        ┌──────────────┐
    │                   │     LLM      │
    │                   │  (streaming) │
    │                   └──────┬───────┘
    │                    tokens│ por cláusula
    │                          ▼
    │                   ┌──────────────┐
    │                   │  TTS stream  │
    │                   │  + buffer    │
    │                   └──────┬───────┘
    │                          ▼
    │      corta y vacía  ┌──────────┐
    └──────────────────── │ parlante │ ──► oye
         detecta VAD      └──────────┘

La rama de la izquierda es el barge-in: mientras el parlante suena, el chunking sigue escuchando el micrófono, y si el VAD detecta voz, el retorno corta el TTS y vacía su buffer. Sin esa rama tienes un pipeline que solo funciona por turnos limpios; con ella, una conversación.

Errores comunes

  • Actuar sobre resultados parciales. El parcial es una hipótesis que el motor va a corregir. Muéstralo en pantalla, pero dispara tools y respuestas solo con el final. Actuar sobre parciales hace que el agente “entienda mal” cosas bien dichas.
  • Reproducir cada chunk de TTS sin buffer. Cualquier microdemora de red deja un silencio en medio de una palabra. Se necesita un colchón de 100–200 ms que se rellena mientras suena.
  • Cortar el TTS por token suelto. Rompe la prosodia: el sintetizador no tiene contexto para entonar. Cortar por cláusula u oración es el punto medio entre naturalidad y latencia.
  • Usar HTTP request/response para audio en vivo. No es full-duplex y cierra la conexión en cada intercambio. WebSocket o WebRTC, no HTTP clásico.
  • Elegir WebSocket para el tramo con el usuario en red mala. En teléfono o móvil, la pérdida de paquetes y el ruido hacen que WebRTC (con jitter buffer, FEC y cancelación de eco) gane claramente. WebSocket luce igual solo en red buena.
  • Barge-in sin vaciar el buffer. Si cortas la síntesis pero no descartas el audio ya encolado, el agente sigue hablando dos segundos después de “callarse”.
  • Barge-in sin actualizar el estado del modelo. Si no le dices al modelo que no terminó de hablar, su próxima respuesta parte de un contexto falso donde ya dijo todo.
  • VAD de puro volumen para detectar interrupciones. Una tos o un “ajá” cortan al agente. El VAD semántico distingue una interrupción real de un ruido de fondo.

La frase para llevarse

“El streaming no es abrir un socket: es aceptar que la transcripción cambia bajo tus pies, que la frase se corta y que el usuario te va a interrumpir. Un agente de voz que se siente vivo es uno que maneja esos tres problemas — no uno que los ignora más rápido.”

Qué viene

Módulo 7: VAD y turn-taking. En este módulo el barge-in y el “fin de turno” aparecieron como cajas que simplemente funcionaban: el VAD detecta que el usuario empezó o dejó de hablar, y ya. Pero decidir cuándo el usuario terminó de hablar es el problema más sutil de toda la conversación por voz — demasiado rápido y cortas al usuario en una pausa para pensar; demasiado lento y el agente se siente dormido. Vamos a abrir esa caja: cómo funciona un VAD, la diferencia entre VAD por energía y VAD semántico, el endpointing, los backchannels (“ajá”, “claro”) que no son turnos, y cómo se afina el umbral para que la conversación se turne como entre humanos.

Material relacionado

VozStreamingWebRTCTiempo realASR

Preguntas sobre este curso

Regístrate gratis para hacer preguntas y guardar tu progreso.

✉ Entérate de lo nuevo

Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.