GPT-Live ouvindo e falando ao mesmo tempo — como funciona e exemplo de integração Java
1. Visão geral
“GPT-Live” é o nome comum para modelos LLM com capacidade de ouvir e falar simultaneamente por um único canal de streaming de áudio, em vez de executar sequencialmente Speech-to-Text → LLM → Text-to-Speech. Este artigo explica como o mecanismo full-duplex funciona no nível do protocolo, depois constrói um exemplo com Spring Boot conectando WebSocket, enviando áudio, recebendo respostas em streaming e tratando a situação em que o usuário interrompe (barge-in). Ao terminar de ler, você terá conhecimento suficiente para escrever seu próprio cliente Java chamando uma API voice em tempo real e saberá as pegadinhas ao colocar essa funcionalidade em produção.
2. Por que o pipeline antigo (STT → LLM → TTS) não é “ao vivo”
Anteriormente, assistentes de voz geralmente montavam 3 modelos separados:
Mic → [Speech-to-Text] → text → [LLM] → text → [Text-to-Speech] → Speaker
Cada etapa espera a anterior terminar antes de rodar. A latência se acumula (normalmente 2–5 segundos), e o usuário não pode interromper no meio da resposta porque o sistema não está “ouvindo” enquanto está falando.
GPT-Live consolida os três papéis em um único modelo multimodal que recebe quadros de áudio diretamente e gera quadros de áudio, por meio de uma conexão bidirecional (duplex) contínua. Não há etapa de “esperar pela frase inteira” — o modelo processa cada pequeno segmento de áudio (chunk) assim que chega, e pode começar a falar a resposta antes mesmo do usuário terminar a frase inteira se já houver confiança semântica.
Nota: “Ao vivo” aqui não significa sem latência, mas sim latência distribuída em chunks (normalmente 20–100ms) em vez de concentrada no final do pipeline.
3. Arquitetura full-duplex: fluxo de eventos por WebSocket
Diferente de uma API REST (request-response uma única vez), as APIs em tempo real usam WebSocket para manter a conexão aberta, transmitindo eventos em formato JSON de ida e volta. Dois fluxos de dados rodam simultaneamente:
- Cliente → servidor: chunk de áudio (PCM16 em base64), evento de controle (iniciar/terminar turno de fala).
- Servidor → cliente: chunk de áudio de resposta, transcrição parcial, evento sinalizando “o modelo detectou que você está falando” (para auto-parar o output).
Cliente Servidor (GPT-Live)
|--- audio.append (chunk1) -->|
|--- audio.append (chunk2) -->|
| |-- transcript.delta (partial) -->
|--- audio.append (chunk3) -->|
|<------ response.audio.delta -| (modelo começou a responder)
|--- audio.append (interrupção)-->|
|<------ response.cancelled ---| (servidor cancela a parte em andamento)
Dois fluxos de áudio rodando em paralelo em um único socket é a essência de “ouvir e falar ao mesmo tempo”.
4. Configurar dependência de conexão WebSocket do Spring Boot
Spring Boot suporta cliente WebSocket nativamente via spring-websocket. Adicione a dependência:
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
Crie uma configuração para conectar ao endpoint em tempo real (exemplo de endpoint fictício wss://api.gptlive.example/v1/voice):
@Configuration
public class VoiceSessionConfig {
@Bean
public StandardWebSocketClient webSocketClient() {
return new StandardWebSocketClient();
}
}
StandardWebSocketClient é a implementação padrão do Spring, suficiente para cliente sem precisar de um container servlet.
5. Enviar chunks de áudio e receber respostas em streaming
A ideia central: cada vez que o microfone grava um segmento de áudio (por exemplo, 20ms), envie imediatamente um evento audio.append para o servidor, sem esperar pela frase inteira.
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" -> playAudioChunk(event.get("audio").asText());
case "response.text.delta" -> System.out.print(event.get("delta").asText());
case "input_audio.speech_started" -> cancelOngoingPlayback();
default -> { /* ignorar eventos não necessários */ }
}
}
private void playAudioChunk(String base64Audio) {
byte[] pcm = Base64.getDecoder().decode(base64Audio);
// enviar pcm para o buffer de saída de áudio do alto-falante
}
private void cancelOngoingPlayback() {
// parar a reprodução de áudio em andamento — este é o mecanismo barge-in
}
}
Explicação:
sendAudioChunké chamado continuamente a partir da thread de gravação do microfone (por exemplo, a cada 20ms), sem agrupar em lotes para manter a latência baixa.handleTextMessageé o callback que recebe eventos do servidor;response.audio.deltasignifica que o modelo está falando, enquantoinput_audio.speech_startedsignifica que o servidor detectou que o usuário começou a falar — neste ponto o cliente deve parar o alto-falante imediatamente.event.get("type").asText()usa oJsonNodedo Jackson para ler o campotypesem precisar definir um DTO rígido — útil porque o esquema de evento pode ser expandido com novos tipos no futuro.
6. Mecanismo barge-in: quem fala e quando?
Barge-in é a capacidade do usuário interromper a IA no meio de uma frase. Para isso funcionar, o servidor precisa de um detector de atividade de voz (Voice Activity Detection — VAD) rodando continuamente no fluxo de áudio de entrada, independentemente se o servidor está emitindo áudio ou não.
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" -> {
// usuário fala enquanto modelo está falando → interromper
if (state == State.MODEL_SPEAKING) {
stopPlaybackImmediately();
}
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 stopPlaybackImmediately() {
System.out.println("Interrompendo reprodução de áudio, enviando response.cancel para servidor");
}
}
Explicação: TurnStateMachine rastreia quem está “segurando o microfone” usando 3 estados. Quando input_audio.speech_started chega durante MODEL_SPEAKING, isso significa que o usuário entrou no meio — o cliente deve parar o alto-falante imediatamente e notificar o servidor para cancelar a resposta em andamento (enviando o evento response.cancel), evitando que o áudio antigo e novo se sobreponham.
Nota: Se você não tratar barge-in, a IA falará por cima do usuário — a pior experiência de um assistente de voz. Essa é a parte mais fácil de pular em demonstrações rápidas mas obrigatória em produção.
7. Gerenciar sessão e contexto na conversa por voz
Como a conexão é com estado (mantém-se durante toda a chamada), você precisa de um objeto de sessão para armazenar histórico de conversa e configurações de voz, separado da lógica de transporte.
public class VoiceSession {
private final String sessionId;
private final List<ConversationTurn> history = new ArrayList<>();
private final String voice; // exemplo "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) {}
}
Ao inicializar a sessão, você envia um evento session.update para configurar a voz de leitura e instrução de sistema antes de começar a gravar áudio:
Map<String, Object> sessionUpdate = Map.of(
"type", "session.update",
"session", Map.of(
"voice", "alloy",
"instructions", "Você é um assistente de agendamento de consultas médicas, responda de forma breve.",
"turn_detection", Map.of("type", "server_vad")
)
);
turn_detection: server_vad significa que você delega ao servidor a detecção de quando o usuário termina de falar usando VAD, em vez de o cliente adivinhar com base em silêncios — essa abordagem é mais precisa porque o servidor pode usar sinais semânticos também (por exemplo, uma frase semanticamente incompleta em relação à gramática ainda não é considerada terminada).
8. Testar lógica de turn-taking com JUnit
A parte de transporte (WebSocket real) é difícil de testar unitariamente, mas a máquina de estado que controla os turnos de fala é completamente testável isoladamente.
class TurnStateMachineTest {
@Test
void quandoModeloEstaFalandoEUsuarioComecaAFalar_deveInterromperReproducao() {
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 quandoNinguemFala_estadoDeveSerIdle() {
TurnStateMachine machine = new TurnStateMachine();
machine.onServerEvent("response.done");
assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
}
}
(Suponha que você adicione um getter getState() a TurnStateMachine.) O primeiro teste confirma que as transições de estado ocorrem na ordem correta quando há uma interrupção; o segundo teste confirma que o sistema retorna a IDLE após a resposta terminar — essa é a condição para o próximo turno de conversa começar sem problemas.
9. Erros comuns e quando não usar
- Enviar áudio em lotes grandes (por exemplo, a cada 2 segundos): anula todo o benefício do streaming, a latência volta a ser como o pipeline antigo. Sempre envie chunks pequenos (20–100ms).
- Não tratar o evento
response.cancel: se você apenas parar a reprodução no cliente sem notificar o servidor para cancelar, o servidor continua gerando áudio extra, desperdiçando custos e podendo causar desincronização na sessão. - Usar VAD do lado do cliente para decidir quando o usuário terminou de falar: tende a cortar a frase muito cedo ou muito tarde em ambientes barulhentos; deixe o VAD do servidor lidar se a API suportar, a menos que você precise funcionar offline.
- Aplicar para casos de uso que não precisam de interação em tempo real (exemplo: gerar relatórios, resumir documentos longos): modelos de voz em tempo real são muito mais caros e complexos do que um pipeline STT/LLM/TTS tradicional, não vale a pena trocar por latência baixa que você não precisa.
- Rodar cliente WebSocket na thread principal da UI sem separar threads para gravação/reprodução de áudio: tende a causar travamentos e congestionamento quando o processamento de JSON bloqueia a thread de áudio.
Nota: Voz full-duplex consome banda e custos de API continuamente durante toda a chamada, não apenas por token de texto. Considere limitar o tempo de sessão ou timeout quando não há atividade.
10. Conclusão
GPT-Live funciona com uma conexão WebSocket mantida aberta, transmitindo chunks de áudio bidirecionalmente e usando VAD para determinar quem está com a vez de falar — esse é o mecanismo que permite “ouvir e falar ao mesmo tempo” em vez de esperar sequencialmente como no pipeline STT/LLM/TTS antigo. Ao integrar em uma aplicação Java/Spring Boot, o trabalho principal é gerenciar a máquina de estado de turn-taking, tratar barge-in corretamente e separar a lógica de sessão do transporte para facilitar testes. Se seu caso de uso não precisa de respostas em tempo real, o pipeline tradicional ainda é a escolha mais simples e barata.