GPT-Live vừa nghe vừa nói — cách hoạt động và ví dụ tích hợp Java

Bài này có bản video ngắn:YouTubeTikTok

1. Tổng quan

“GPT-Live” là cách gọi chung cho các mô hình LLM có khả năng nghe và nói cùng lúc qua một kênh audio streaming duy nhất, thay vì chạy tuần tự Speech-to-Text → LLM → Text-to-Speech. Bài này giải thích cơ chế full-duplex đó hoạt động thế nào ở tầng giao thức, rồi dựng một ví dụ Spring Boot kết nối WebSocket, gửi audio, nhận phản hồi streaming và xử lý tình huống người dùng ngắt lời (barge-in). Đọc xong, bạn sẽ hiểu đủ để tự viết client Java gọi một realtime voice API và biết những cạm bẫy khi đưa tính năng này vào production.

2. Vì sao pipeline cũ (STT → LLM → TTS) không “live”

Trước đây, voice assistant thường ráp 3 mô hình riêng:

Mic → [Speech-to-Text] → text → [LLM] → text → [Text-to-Speech] → Speaker

Mỗi bước chờ bước trước hoàn tất mới chạy. Độ trễ cộng dồn (thường 2–5 giây), và người dùng không thể ngắt lời giữa câu trả lời vì hệ thống không “lắng nghe” khi đang nói.

GPT-Live gộp cả ba vai trò vào một mô hình đa phương thức (multimodal), nhận trực tiếp audio frame và sinh ra audio frame, qua một kết nối hai chiều (duplex) liên tục. Không có bước “chờ xong câu” — mô hình xử lý từng đoạn âm thanh nhỏ (chunk) ngay khi chúng tới, và có thể bắt đầu phát âm trả lời trước khi người dùng dứt câu hoàn toàn nếu đã đủ tin cậy ngữ nghĩa.

Lưu ý: “Live” ở đây không có nghĩa là không có độ trễ, mà là độ trễ được chia nhỏ theo từng chunk (thường 20–100ms) thay vì dồn vào cuối pipeline.

3. Kiến trúc full-duplex: event stream qua WebSocket

Khác với REST API (request-response một lần), các API realtime dùng WebSocket để giữ kết nối mở, truyền qua lại các event dạng JSON. Hai luồng dữ liệu chạy đồng thời:

  • Client → server: audio chunk (base64 PCM16), event điều khiển (bắt đầu/kết thúc lượt nói).
  • Server → client: audio chunk phản hồi, transcript từng phần, event báo “mô hình phát hiện bạn đang nói” (để tự ngắt output).
Client                         Server (GPT-Live)
  |--- audio.append (chunk1) -->|
  |--- audio.append (chunk2) -->|
  |                             |-- transcript.delta (partial) -->
  |--- audio.append (chunk3) -->|
  |<------ response.audio.delta -|  (mô hình đã bắt đầu trả lời)
  |--- audio.append (ngắt lời)-->|
  |<------ response.cancelled ---|  (server tự hủy phần đang nói)

Việc hai luồng audio chạy song song trên một socket chính là bản chất của “vừa nghe vừa nói”.

4. Thiết lập dependency kết nối WebSocket từ Spring Boot

Spring Boot hỗ trợ sẵn WebSocket client qua spring-websocket. Thêm dependency:

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-json</artifactId>
</dependency>

Tạo cấu hình kết nối tới endpoint realtime (ví dụ giả định wss://api.gptlive.example/v1/voice):

@Configuration
public class VoiceSessionConfig {

    @Bean
    public StandardWebSocketClient webSocketClient() {
        return new StandardWebSocketClient();
    }
}

StandardWebSocketClient là implementation chuẩn của Spring, đủ dùng cho client-side không cần container servlet.

5. Gửi audio chunk và nhận phản hồi streaming

Ý tưởng cốt lõi: mỗi khi mic ghi được một đoạn audio (ví dụ 20ms), gửi ngay một event audio.append lên server, không chờ đủ cả câu.

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 -> { /* bỏ qua event không cần xử lý */ }
        }
    }

    private void playAudioChunk(String base64Audio) {
        byte[] pcm = Base64.getDecoder().decode(base64Audio);
        // đẩy pcm vào audio output buffer của loa
    }

    private void cancelOngoingPlayback() {
        // dừng phát audio đang chạy — đây là cơ chế barge-in
    }
}

Giải thích:

  • sendAudioChunk được gọi liên tục từ luồng ghi mic (ví dụ mỗi 20ms), không gộp batch để giữ độ trễ thấp.
  • handleTextMessage là callback nhận event từ server; response.audio.delta nghĩa là mô hình đang nói, còn input_audio.speech_started nghĩa là server phát hiện người dùng bắt đầu nói — lúc này client phải dừng loa ngay.
  • event.get("type").asText() dùng Jackson JsonNode để đọc field type mà không cần định nghĩa DTO cứng — hữu ích vì schema event có thể mở rộng thêm loại mới.

6. Cơ chế barge-in: ai được nói lúc nào?

Barge-in là khả năng người dùng ngắt lời AI giữa câu. Để làm được, server cần một bộ phát hiện hoạt động giọng nói (Voice Activity Detection — VAD) chạy liên tục trên luồng audio đầu vào, độc lập với việc server có đang phát audio ra hay không.

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" -> {
                // người dùng nói trong khi model đang nói → ngắt lời
                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("Ngắt phát audio, gửi response.cancel lên server");
    }
}

Giải thích: TurnStateMachine theo dõi ai đang “giữ mic” bằng 3 trạng thái. Khi input_audio.speech_started đến trong lúc MODEL_SPEAKING, điều đó nghĩa là người dùng chen ngang — client phải dừng loa ngay và báo server hủy response đang sinh (gửi event response.cancel), tránh audio cũ và audio mới chồng lên nhau.

Lưu ý: Nếu không xử lý barge-in, AI sẽ nói đè lên người dùng — trải nghiệm khó chịu nhất của voice assistant. Đây là phần dễ bị bỏ sót khi demo nhanh nhưng bắt buộc phải có ở production.

7. Quản lý session và context trong hội thoại thoại

Vì kết nối là stateful (giữ nguyên trong suốt cuộc gọi), bạn cần một đối tượng session để lưu lịch sử hội thoại và cấu hình giọng nói, tách biệt với logic transport.

public class VoiceSession {

    private final String sessionId;
    private final List<ConversationTurn> history = new ArrayList<>();
    private final String voice; // ví dụ "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) {}
}

Khi khởi tạo session, bạn gửi event session.update để thiết lập giọng đọc và instruction hệ thống trước khi bắt đầu ghi audio:

Map<String, Object> sessionUpdate = Map.of(
    "type", "session.update",
    "session", Map.of(
        "voice", "alloy",
        "instructions", "Bạn là trợ lý đặt lịch khám bệnh, trả lời ngắn gọn.",
        "turn_detection", Map.of("type", "server_vad")
    )
);

turn_detection: server_vad nghĩa là bạn nhường việc phát hiện khi nào người dùng dứt câu cho server xử lý bằng VAD, thay vì client tự đoán bằng khoảng lặng — cách này chính xác hơn vì server có thể dùng cả tín hiệu ngữ nghĩa (ví dụ câu chưa hoàn chỉnh về ngữ pháp thì chưa coi là dứt lời).

8. Kiểm thử logic turn-taking bằng JUnit

Phần transport (WebSocket thật) khó test đơn vị, nhưng state machine điều khiển lượt nói thì hoàn toàn test được độc lập.

class TurnStateMachineTest {

    @Test
    void khiModelDangNoiVaUserBatDauNoi_thiPhaiNgatPlayback() {
        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 khiKhongAiNoi_thiTrangThaiLaIdle() {
        TurnStateMachine machine = new TurnStateMachine();

        machine.onServerEvent("response.done");

        assertThat(machine.getState()).isEqualTo(TurnStateMachine.State.IDLE);
    }
}

(Giả sử bạn thêm getter getState() vào TurnStateMachine.) Test đầu tiên xác nhận chuyển trạng thái đúng thứ tự khi có ngắt lời; test thứ hai xác nhận hệ thống trở lại IDLE sau khi response hoàn tất — đây là điều kiện để vòng lặp hội thoại tiếp theo bắt đầu sạch sẽ.

9. Lỗi thường gặp và khi nào không nên dùng

  • Gửi audio theo batch lớn (ví dụ 2 giây một lần): triệt tiêu toàn bộ lợi ích streaming, độ trễ lại giống pipeline cũ. Luôn gửi chunk nhỏ (20–100ms).
  • Không xử lý event response.cancel: nếu bạn chỉ dừng phát audio ở client mà không báo server hủy, server vẫn tiếp tục sinh audio dư, tốn chi phí và có thể gây lệch trạng thái session.
  • Dùng VAD phía client để tự quyết định khi nào người dùng dứt câu: dễ cắt câu quá sớm hoặc quá muộn trong môi trường ồn; nên để server VAD xử lý nếu API hỗ trợ, trừ khi bạn cần hoạt động offline.
  • Áp dụng cho use case không cần tương tác tức thời (ví dụ tạo báo cáo, tóm tắt tài liệu dài): mô hình realtime voice đắt hơn và phức tạp hơn pipeline STT/LLM/TTS truyền thống rất nhiều, không đáng để đổi lấy độ trễ thấp mà bạn không cần.
  • Chạy WebSocket client trên thread chính của UI mà không tách riêng thread ghi/phát audio: dễ gây giật, nghẽn khi xử lý JSON parsing chặn luồng audio.

Lưu ý: Full-duplex voice tiêu tốn băng thông và chi phí API liên tục trong suốt cuộc gọi, không chỉ tính theo token văn bản. Cân nhắc giới hạn thời gian session hoặc timeout khi không có hoạt động.

10. Kết luận

GPT-Live hoạt động dựa trên một kết nối WebSocket giữ mở, truyền audio chunk hai chiều và dùng VAD để xác định ai đang giữ lượt nói — đó là cơ chế cho phép “vừa nghe vừa nói” thay vì chờ tuần tự như pipeline STT/LLM/TTS cũ. Khi tích hợp vào ứng dụng Java/Spring Boot, phần việc chính của bạn là quản lý state machine turn-taking, xử lý barge-in đúng cách, và tách rõ logic session khỏi transport để dễ test. Nếu use case của bạn không cần phản hồi theo thời gian thực, pipeline truyền thống vẫn là lựa chọn đơn giản và rẻ hơn.

llmrealtime-aispring-bootwebsocketvoice-ai