GPT-Live 동시 음성 인식 및 생성 — 작동 원리와 Java 통합 예제

이 글에는 짧은 영상이 있습니다:YouTubeTikTok

1. 개요

“GPT-Live”는 Speech-to-Text → LLM → Text-to-Speech를 순차적으로 실행하는 대신, 하나의 음성 스트리밍 채널을 통해 동시에 듣고 말할 수 있는 LLM 모델을 지칭하는 일반적인 용어입니다. 이 글에서는 프로토콜 계층에서 이 full-duplex 메커니즘이 어떻게 작동하는지 설명하고, WebSocket을 연결하고 오디오를 송수신하며 사용자가 말을 끝내기 전에 응답하는 상황(barge-in)을 처리하는 Spring Boot 예제를 구축합니다. 이 글을 읽고 나면, 실시간 음성 API를 호출하는 Java 클라이언트를 직접 작성할 수 있고 프로덕션에 이 기능을 배포할 때의 주의점을 알게 될 것입니다.

2. 기존 파이프라인(STT → LLM → TTS)이 “라이브”가 아닌 이유

이전에는 음성 어시스턴트가 보통 3개의 분리된 모델을 조합했습니다:

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

각 단계는 이전 단계가 완료될 때까지 기다립니다. 지연이 누적되어 보통 2-5초가 되고, 사용자는 시스템이 말하는 도중에 “듣지” 않기 때문에 응답 중간에 말을 끝낼 수 없습니다.

GPT-Live는 세 가지 역할을 모두 하나의 멀티모달 모델로 통합하여 오디오 프레임을 직접 수신하고 생성하며, 지속적인 양방향(duplex) 연결을 통해 이를 수행합니다. “문장이 끝날 때까지 기다리는” 단계가 없습니다 — 모델은 각 작은 오디오 청크(chunk)가 도착하는 즉시 처리하며, 의미론적 신뢰도가 충분하다면 사용자가 문장을 완전히 마치기 전에 응답 음성을 생성하기 시작할 수 있습니다.

주의: 여기서 “라이브”는 지연이 없다는 의미가 아니라, 지연이 파이프라인 끝에 누적되는 대신 각 청크(보통 20-100ms)로 분산된다는 의미입니다.

3. Full-duplex 아키텍처: WebSocket을 통한 이벤트 스트림

REST API(일회성 request-response)와 달리, 실시간 API는 WebSocket을 사용하여 연결을 열린 상태로 유지하고 JSON 형식의 이벤트를 주고받습니다. 두 개의 데이터 스트림이 동시에 실행됩니다:

  • Client → server: 오디오 청크(base64 PCM16), 제어 이벤트(음성 시작/종료).
  • Server → client: 응답 오디오 청크, 부분 텍스트 기록, “모델이 당신이 말하는 것을 감지했습니다” 이벤트(출력을 자동으로 중지하기 위해).
Client                         Server (GPT-Live)
  |--- audio.append (chunk1) -->|
  |--- audio.append (chunk2) -->|
  |                             |-- transcript.delta (partial) -->
  |--- audio.append (chunk3) -->|
  |<------ response.audio.delta -|  (모델이 응답 생성을 시작했습니다)
  |--- audio.append (말 끼어들기)-->|
  |<------ response.cancelled ---|  (서버가 진행 중인 부분을 취소했습니다)

두 오디오 스트림이 단일 소켓에서 동시에 실행되는 것이 바로 “동시에 듣고 말하는” 본질입니다.

4. Spring Boot에서 WebSocket 연결 설정

Spring Boot는 spring-websocket을 통해 WebSocket 클라이언트를 지원합니다. 의존성을 추가합니다:

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

실시간 엔드포인트에 연결하기 위한 설정을 만듭니다(예: wss://api.gptlive.example/v1/voice):

@Configuration
public class VoiceSessionConfig {

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

StandardWebSocketClient는 Spring의 표준 구현이며, 서블릿 컨테이너가 필요하지 않은 클라이언트 측에 충분합니다.

5. 오디오 청크 전송 및 스트리밍 응답 수신

핵심 아이디어: 마이크가 오디오 세그먼트를 기록할 때마다(예: 20ms), 전체 문장을 기다리지 않고 즉시 서버에 audio.append 이벤트를 보냅니다.

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 -> { /* 처리할 필요가 없는 이벤트는 무시 */ }
        }
    }

    private void playAudioChunk(String base64Audio) {
        byte[] pcm = Base64.getDecoder().decode(base64Audio);
        // pcm을 스피커의 오디오 출력 버퍼로 전송
    }

    private void cancelOngoingPlayback() {
        // 현재 재생 중인 오디오 중지 — 이것이 barge-in 메커니즘
    }
}

설명:

  • sendAudioChunk은 마이크 녹음 스레드에서 지속적으로 호출되며(예: 20ms마다) 낮은 지연을 유지하기 위해 배치로 묶지 않습니다.
  • handleTextMessage은 서버에서 이벤트를 수신하는 콜백입니다. response.audio.delta는 모델이 말하고 있다는 뜻이고, input_audio.speech_started는 서버가 사용자가 말하기 시작했음을 감지했다는 뜻입니다 — 이 경우 클라이언트는 즉시 스피커를 중지해야 합니다.
  • event.get("type").asText()는 Jackson의 JsonNode를 사용하여 고정된 DTO 정의 없이 type 필드를 읽습니다 — 이벤트 스키마가 새로운 유형으로 확장될 수 있으므로 유용합니다.

6. Barge-in 메커니즘: 누가 언제 말할 것인가?

Barge-in은 사용자가 AI 응답 중간에 말을 끝낼 수 있는 기능입니다. 이를 구현하려면 서버가 입력 오디오 스트림에서 지속적으로 실행되는 음성 활동 감지기(Voice Activity Detection — VAD)를 가져야 하며, 이는 서버가 오디오를 재생하고 있는지 여부와 무관합니다.

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" -> {
                // 모델이 말하는 중에 사용자가 말함 → 말 끼어들기
                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("오디오 재생 중지, 서버에 response.cancel 전송");
    }
}

설명: TurnStateMachine은 3가지 상태로 누가 “마이크를 잡고 있는지” 추적합니다. input_audio.speech_started가 MODEL_SPEAKING 중에 도착하면, 사용자가 말을 끼어든다는 뜻입니다 — 클라이언트는 즉시 스피커를 중지하고 서버에 진행 중인 응답을 취소하라고 알려야 합니다(response.cancel 이벤트 전송), 이전 오디오와 새 오디오가 겹치는 것을 방지합니다.

주의: Barge-in을 처리하지 않으면 AI가 사용자 위에서 말할 것입니다 — 음성 어시스턴트의 가장 불쾌한 경험입니다. 이는 빠른 데모에서 쉽게 간과되지만 프로덕션에서는 필수입니다.

7. 음성 대화에서 세션 및 컨텍스트 관리

연결이 stateful이므로(통화 내내 유지됨), 트랜스포트 로직과 분리하여 대화 기록과 음성 구성을 저장하기 위한 세션 객체가 필요합니다.

public class VoiceSession {

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

세션을 초기화할 때, 오디오 녹음을 시작하기 전에 session.update 이벤트를 보내 음성 음색과 시스템 지침을 설정합니다:

Map<String, Object> sessionUpdate = Map.of(
    "type", "session.update",
    "session", Map.of(
        "voice", "alloy",
        "instructions", "당신은 병원 예약을 도와주는 어시스턴트이며, 간단하게 답변하세요.",
        "turn_detection", Map.of("type", "server_vad")
    )
);

turn_detection: server_vad는 사용자가 문장을 완료할 시기를 감지하는 것을 서버의 VAD로 처리하도록 하며, 클라이언트가 침묵으로 추측하는 대신입니다 — 서버가 의미론적 신호를 사용할 수 있으므로(예: 문법적으로 불완전한 문장은 여전히 진행 중으로 간주) 이것이 더 정확합니다.

8. JUnit으로 turn-taking 로직 테스트하기

Transport 부분(실제 WebSocket)은 단위 테스트하기 어렵지만, turn-taking을 제어하는 상태 머신은 완벽하게 독립적으로 테스트할 수 있습니다.

class TurnStateMachineTest {

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

        machine.onServerEvent("response.done");

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

(TurnStateMachine에 getter getState()를 추가했다고 가정합니다.) 첫 번째 테스트는 말을 끼어들 때 상태 전환이 올바른 순서로 진행되는지 확인합니다. 두 번째 테스트는 응답이 완료된 후 시스템이 IDLE로 돌아가는지 확인합니다 — 이는 다음 대화 루프가 깨끗하게 시작되기 위한 조건입니다.

9. 일반적인 오류 및 사용하지 말아야 할 경우

  • 큰 배치로 오디오 전송(예: 2초마다): 스트리밍의 모든 이점을 무효화하고 지연이 다시 이전 파이프라인처럼 됩니다. 항상 작은 청크를 보냅니다(20-100ms).
  • response.cancel 이벤트를 처리하지 않기: 클라이언트에서만 오디오 재생을 중지하고 서버에 취소를 알리지 않으면 서버는 계속 추가 오디오를 생성하여 비용을 낭비하고 세션 상태를 불일치시킬 수 있습니다.
  • 클라이언트 측 VAD를 사용하여 사용자가 문장을 완료한 시점을 결정하기: 시끄러운 환경에서 문장을 너무 일찍 또는 너무 늦게 자르기 쉽습니다. API가 지원하면 서버 VAD 처리에 맡기는 것이 좋으며, 오프라인 작동이 필요한 경우가 아니면 그렇게 하세요.
  • 실시간 상호작용이 필요하지 않은 용도에 적용하기(예: 보고서 생성, 긴 문서 요약): 실시간 음성 모델은 기존 STT/LLM/TTS 파이프라인보다 훨씬 비싸고 복잡하며, 필요 없는 낮은 지연을 위해 그 비용을 감수할 가치가 없습니다.
  • UI의 주 스레드에서 WebSocket 클라이언트 실행(오디오 녹음/재생 스레드를 분리하지 않음): JSON 파싱이 오디오 스트림을 차단할 때 끊김 및 지연 문제가 발생하기 쉽습니다.

주의: Full-duplex 음성은 텍스트 토큰으로만 계산하지 않고 통화 내내 지속적으로 대역폭과 API 비용을 소비합니다. 세션 시간 제한 또는 비활성 타임아웃을 고려하세요.

10. 결론

GPT-Live는 열린 상태로 유지되는 WebSocket 연결, 양방향 오디오 청크 전송, 그리고 누가 발언권을 가지고 있는지 결정하기 위해 VAD를 사용하는 것에 기반을 두고 있습니다 — 이것은 기존 STT/LLM/TTS 파이프라인처럼 순차적으로 기다리는 대신 “동시에 듣고 말하는” 것을 가능하게 하는 메커니즘입니다. Java/Spring Boot 애플리케이션에 통합할 때, 주요 작업은 turn-taking 상태 머신을 관리하고, barge-in을 올바르게 처리하며, 세션 로직을 트랜스포트에서 분리하여 테스트를 쉽게 만드는 것입니다. 사용 사례에 실시간 응답이 필요하지 않다면, 기존 파이프라인은 여전히 더 간단하고 저렴한 선택입니다.

llmrealtime-aispring-bootwebsocketvoice-ai