GPT-Live ouvindo e falando ao mesmo tempo — como funciona e exemplo de integração Java

Este artigo tem um vídeo curto:YouTubeTikTok

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.delta significa que o modelo está falando, enquanto input_audio.speech_started significa 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 o JsonNode do Jackson para ler o campo type sem 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.

llmrealtime-aispring-bootwebsocketvoice-ai