GPT-Live: voz en tiempo real — arquitectura full-duplex con Java
1. Descripción general
“GPT-Live” es la designación común para modelos LLM capaces de escuchar y hablar simultáneamente a través de un único canal de audio en streaming, en lugar de ejecutar secuencialmente Speech-to-Text → LLM → Text-to-Speech. Este artículo explica cómo funciona ese mecanismo full-duplex a nivel de protocolo, luego construye un ejemplo con Spring Boot que conecta WebSocket, envía audio, recibe respuestas en streaming y maneja la situación donde el usuario interrumpe (barge-in). Después de leerlo, tendrás suficiente comprensión para escribir tu propio cliente Java que llame a una API de voz en tiempo real y conocer las trampas al llevar esta funcionalidad a producción.
2. Por qué el pipeline antiguo (STT → LLM → TTS) no es “en vivo”
Antes, los asistentes de voz acostumbraban conectar 3 modelos separados:
Micrófono → [Speech-to-Text] → texto → [LLM] → texto → [Text-to-Speech] → Altavoz
Cada paso espera a que termine el anterior. La latencia acumulada (generalmente 2–5 segundos), y el usuario no puede interrumpir en medio de una respuesta porque el sistema no está “escuchando” mientras habla.
GPT-Live combina los tres roles en un modelo multimodal único que recibe directamente fotogramas de audio y genera fotogramas de audio, a través de una conexión bidireccional (duplex) continua. No hay pasos de “esperar hasta terminar la oración” — el modelo procesa cada pequeño fragmento de audio (chunk) tan pronto como llega, y puede comenzar a responder antes de que el usuario termine completamente su oración si ya tiene suficiente confianza semántica.
Nota: “En vivo” aquí no significa sin latencia, sino que la latencia se divide en pequeños fragmentos (típicamente 20–100ms) en lugar de acumularse al final del pipeline.
3. Arquitectura full-duplex: flujo de eventos a través de WebSocket
A diferencia de una API REST (request-response único), las APIs en tiempo real usan WebSocket para mantener la conexión abierta, transmitiendo bidireccionalamente eventos en formato JSON. Dos flujos de datos corren simultáneamente:
- Cliente → servidor: fragmentos de audio (PCM16 en base64), eventos de control (iniciar/terminar turno).
- Servidor → cliente: fragmentos de audio de respuesta, transcripción parcial, evento que informa “el modelo detectó que estás hablando” (para auto-detener la salida).
Cliente Servidor (GPT-Live)
|--- audio.append (chunk1) -->|
|--- audio.append (chunk2) -->|
| |-- transcript.delta (parcial) -->
|--- audio.append (chunk3) -->|
|<------ response.audio.delta -| (el modelo comenzó a responder)
|--- audio.append (interrumpe)->|
|<------ response.cancelled ---| (servidor cancela la parte que está generando)
El hecho de que dos flujos de audio corran en paralelo en un único socket es la esencia de “escuchar y hablar simultáneamente”.
4. Configuración de dependencias para conexión WebSocket desde Spring Boot
Spring Boot proporciona soporte nativo para cliente WebSocket a través de spring-websocket. Agrega las dependencias:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
Crea una configuración para conectar al endpoint en tiempo real (por ejemplo, wss://api.gptlive.example/v1/voice):
@Configuration
public class VoiceSessionConfig {
@Bean
public StandardWebSocketClient webSocketClient() {
return new StandardWebSocketClient();
}
}
StandardWebSocketClient es la implementación estándar de Spring, suficiente para uso del lado cliente sin necesidad de contenedor servlet.
5. Enviar fragmentos de audio y recibir respuestas en streaming
La idea central: cada vez que el micrófono captura un fragmento de audio (por ejemplo 20ms), envía inmediatamente un evento audio.append al servidor, sin esperar a que la oración se complete.
public class VoiceSessionHandler extends TextWebSocketHandler {
private final ObjectMapper mapper = new ObjectMapper();
public void sendAudioChunk(WebSocketSession session, byte[] pcmChunk) throws IOException {
String base64Audio = Base64.getEncoder().encodeToString(pcmChunk);
Map<String, Object> event = Map.of(
"type", "input_audio.append",
"audio", base64Audio
);
session.sendMessage(new TextMessage(mapper.writeValueAsString(event)));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
JsonNode event = mapper.readTree(message.getPayload());
String type = event.get("type").asText();
switch (type) {
case "response.audio.delta" -> reproducirFragmentoAudio(event.get("audio").asText());
case "response.text.delta" -> System.out.print(event.get("delta").asText());
case "input_audio.speech_started" -> detenerReproduccionActual();
default -> { /* ignorar eventos no necesarios */ }
}
}
private void reproducirFragmentoAudio(String base64Audio) {
byte[] pcm = Base64.getDecoder().decode(base64Audio);
// empujar pcm al buffer de salida de audio del altavoz
}
private void detenerReproduccionActual() {
// detener el audio que se está reproduciendo — este es el mecanismo barge-in
}
}
Explicación:
sendAudioChunkse llama continuamente desde el hilo de grabación del micrófono (por ejemplo cada 20ms), sin agrupar por lotes para mantener la latencia baja.handleTextMessagees el callback que recibe eventos del servidor;response.audio.deltasignifica que el modelo está hablando, mientras queinput_audio.speech_startedsignifica que el servidor detectó que el usuario comenzó a hablar — en este momento el cliente debe detener el altavoz inmediatamente.event.get("type").asText()usa JacksonJsonNodepara leer el campotypesin necesidad de definir un DTO rígido — útil porque el esquema de eventos puede expandirse con nuevos tipos.
6. Mecanismo barge-in: ¿quién puede hablar y cuándo?
Barge-in es la capacidad del usuario de interrumpir al asistente de IA en medio de una oración. Para lograrlo, el servidor necesita un detector de actividad de voz (Voice Activity Detection — VAD) que corra continuamente en el flujo de audio de entrada, independientemente de si el servidor está reproduciendo audio o no.
public class TurnStateMachine {
public enum State { IDLE, USER_SPEAKING, MODEL_SPEAKING }
private volatile State state = State.IDLE;
public void onServerEvent(String eventType) {
state = switch (eventType) {
case "input_audio.speech_started" -> {
// usuario hablando mientras el modelo está hablando → interrumpir
if (state == State.MODEL_SPEAKING) {
detenerReproduccionInmediatamente();
}
yield State.USER_SPEAKING;
}
case "input_audio.speech_stopped" -> State.IDLE;
case "response.audio.delta" -> State.MODEL_SPEAKING;
case "response.done" -> State.IDLE;
default -> state;
};
}
private void detenerReproduccionInmediatamente() {
System.out.println("Detener reproducción de audio, enviar response.cancel al servidor");
}
}
Explicación: TurnStateMachine rastrea quién “tiene el micrófono” usando 3 estados. Cuando input_audio.speech_started llega mientras estamos en MODEL_SPEAKING, significa que el usuario está interrumpiendo — el cliente debe detener el altavoz inmediatamente e informar al servidor que cancele la respuesta que está generando (enviando el evento response.cancel), para evitar que audio antiguo y nuevo se superpongan.
Nota: Si no manejas barge-in, la IA hablará sobre el usuario — la experiencia más desagradable en un asistente de voz. Esta es la parte más fácil de ignorar en una demostración, pero es imprescindible en producción.
7. Gestión de sesiones y contexto en la conversación
Porque la conexión es stateful (se mantiene durante toda la llamada), necesitas un objeto de sesión para guardar el historial de conversación y configuración de voz, separado de la lógica de transporte.
public class VoiceSession {
private final String sessionId;
private final List<ConversationTurn> history = new ArrayList<>();
private final String voice; // por ejemplo "alloy", "verse"
public VoiceSession(String sessionId, String voice) {
this.sessionId = sessionId;
this.voice = voice;
}
public void appendTurn(String role, String transcript) {
history.add(new ConversationTurn(role, transcript, Instant.now()));
}
public record ConversationTurn(String role, String transcript, Instant at) {}
}
Cuando inicializas la sesión, envías un evento session.update para establecer la voz de lectura e instrucciones del sistema antes de comenzar a grabar audio:
Map<String, Object> sessionUpdate = Map.of(
"type", "session.update",
"session", Map.of(
"voice", "alloy",
"instructions", "Eres un asistente de citas para consultas médicas, responde de forma breve.",
"turn_detection", Map.of("type", "server_vad")
)
);
turn_detection: server_vad significa que delegas al servidor la detección de cuándo el usuario termina de hablar usando VAD, en lugar de que el cliente lo adivine por medio de silencios — este enfoque es más preciso porque el servidor puede usar también señales semánticas (por ejemplo, una oración incompleta gramaticalmente aún no se considera terminada).
8. Pruebas unitarias de la lógica de turnos con JUnit
La parte de transporte (WebSocket real) es difícil de hacer test unitario, pero la máquina de estados que controla los turnos es completamente comprobable de forma independiente.
class TurnStateMachineTest {
@Test
void cuandoModeloHablaYUsuarioComienzaAHablar_debeDetenerReproduccion() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.audio.delta");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.MODEL_SPEAKING);
machine.onServerEvent("input_audio.speech_started");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.USER_SPEAKING);
}
@Test
void cuandoNadieHabla_estadoDebeSerIdle() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.done");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
}
}
(Asume que agregaste un getter getState() a TurnStateMachine.) La primera prueba confirma que la transición de estado sea correcta cuando hay una interrupción; la segunda prueba confirma que el sistema vuelva a IDLE después de que la respuesta se complete — esta es la condición para que el siguiente ciclo de conversación comience limpiamente.
9. Errores comunes y cuándo no deberías usar esto
- Enviar audio por lotes grandes (por ejemplo cada 2 segundos): elimina todos los beneficios del streaming, la latencia vuelve a ser como en el pipeline antiguo. Siempre envía chunks pequeños (20–100ms).
- No manejar el evento
response.cancel: si solo detienes la reproducción en el cliente pero no informas al servidor que cancele, el servidor seguirá generando audio sobrante, desperdiciando recursos y posiblemente causando desincronización de estado de sesión. - Usar VAD del lado del cliente para decidir por sí solo cuándo el usuario termina de hablar: es fácil cortar la oración demasiado pronto o demasiado tarde en entornos ruidosos; es mejor dejar que el VAD del servidor maneje esto si la API lo soporta, a menos que necesites operar sin conexión.
- Aplicar esto para casos de uso que no necesitan interacción en tiempo real (por ejemplo generar reportes, resumir documentos largos): los modelos de voz en tiempo real son mucho más caros y complejos que el pipeline STT/LLM/TTS tradicional, no vale la pena cambiar solo por baja latencia que no necesitas.
- Ejecutar el cliente WebSocket en el hilo principal de la UI sin separar el hilo de grabación/reproducción de audio: fácil de causar tartamudeo, bloqueo cuando el análisis JSON obstruye el flujo de audio.
Nota: El audio full-duplex consume ancho de banda y costo de API continuamente durante toda la llamada, no solo se cuenta por tokens de texto. Considera limitar la duración de la sesión o timeout cuando no hay actividad.
10. Conclusión
GPT-Live funciona basándose en una conexión WebSocket abierta que transmite fragmentos de audio bidireccionales y usa VAD para determinar quién tiene el turno — ese es el mecanismo que permite “escuchar y hablar simultáneamente” en lugar de esperar secuencialmente como en el pipeline STT/LLM/TTS antiguo. Al integrarlo en una aplicación Java/Spring Boot, tu trabajo principal es gestionar la máquina de estados de turnos, manejar barge-in correctamente, y separar la lógica de sesión del transporte para facilitar las pruebas. Si tu caso de uso no requiere respuestas en tiempo real, el pipeline tradicional sigue siendo la opción más simple y económica.