음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

브라우저 마이크에서 서버까지 — 음성봇 오디오 스트리밍 프로토콜 설계

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다.

  1. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법
  2. 2. "말이 끝났다"는 판단 — VAD 엔드포인터
  3. 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계
  4. 4. 로컬 LLM 대화 두뇌와 도구 호출 루프
  5. 5. 매 턴 지연시간을 재는 이유
  6. 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 — 지금 보는 글
  7. 7. 로컬 Whisper STT 연결하기
  8. 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결)

음성봇을 직접 만들면서 가장 먼저 손댄 코드는 STT도 TTS도 아니었습니다. 브라우저 마이크에서 잡은 소리를 서버로 어떻게 보낼지, 서버가 합성한 목소리를 브라우저가 어떻게 받아 재생할지를 정하는 일이었습니다. 이 결정이 나머지 모든 컴포넌트의 전제가 되기 때문입니다. STT가 몇 밀리초 단위로 오디오를 받을지, TTS가 어떤 단위로 소리를 흘려보낼지가 전부 이 프로토콜에 묶여 있습니다. 이번 편은 브라우저와 서버가 하나의 WebSocket 위에서 오디오와 텍스트를 어떻게 나눠 보내는지, 그 와이어 포맷을 직접 설계한 과정을 정리한 기록입니다.

1. 왜 소켓 하나에 오디오와 JSON을 함께 태우나

음성봇을 켜 놓은 브라우저 화면에서는 여러 가지가 동시에 일어납니다. 마이크로 잡은 소리가 계속 서버로 나가고, 서버가 만든 목소리가 계속 들어오고, 그와 동시에 대화록이 갱신되고, "듣는 중"·"말하는 중" 같은 상태 표시도 바뀝니다. 이걸 소켓 두 개(오디오용, 이벤트용)로 나눌 수도 있었지만, 그러면 두 연결의 생명주기를 따로 관리해야 하고 재연결·인증도 두 번 신경 써야 합니다.

그래서 하나의 WebSocket 위에 프레임 타입을 두 가지 두기로 했습니다. 오디오처럼 초당 수십 번 오가는 데이터는 바이너리 프레임으로, 대화록·상태 이벤트·제어 명령처럼 상대적으로 드물게 오가는 데이터는 텍스트(JSON) 프레임으로 보냅니다. 같은 소켓이라도 프레임 타입만 보면 수신 쪽에서 바로 구분할 수 있습니다.

class MsgType(str, Enum):
    AUDIO = "audio"          # client -> server: mic PCM16 (binary frame)
    TRANSCRIPT = "transcript"  # server -> client: STT result (JSON)
    TTS_AUDIO = "tts_audio"  # server -> client: synthesized PCM16 (binary frame)
    EVENT = "event"           # server -> client: status/lifecycle event (JSON)
    CONTROL = "control"       # client -> server: control command (JSON)

2. 헤더를 5바이트로 줄인 이유

바이너리 프레임의 헤더 설계에서 가장 신경 쓴 부분은 크기입니다. 오디오는 20ms마다 한 프레임씩, 초당 약 50번 오갑니다. 만약 이걸 JSON으로("type": "audio", "seq": 123, "pcm": "...") 감싸고 PCM 바이트를 base64로 인코딩해 실었다면, 텍스트 인코딩 오버헤드에 base64가 원본 대비 약 33% 부풀리는 비용까지 초당 50번씩 누적됩니다. 실시간성이 생명인 오디오 경로에 그런 낭비를 둘 이유가 없었습니다.

그래서 헤더를 타입 코드 1바이트 + 시퀀스 번호 4바이트(빅엔디안), 총 5바이트로 최소화했습니다. 그 뒤에 PCM16 원본 바이트를 그대로 붙입니다. 파싱도 가볍습니다.

_HEADER = struct.Struct(">BI")   # 1바이트 타입코드 + 4바이트 uint32 시퀀스(BE)
HEADER_SIZE = _HEADER.size        # 5

def to_bytes(self) -> bytes:
    code = _TYPE_TO_CODE.get(self.type)
    return _HEADER.pack(code, self.seq) + self.pcm

@classmethod
def from_bytes(cls, raw: bytes) -> "ClientMessage":
    code, seq = _HEADER.unpack_from(raw)
    msg_type = _CODE_TO_TYPE.get(code)
    return cls(type=msg_type, seq=seq, pcm=raw[HEADER_SIZE:])

시퀀스 번호를 굳이 넣은 것도 실시간 스트림이기 때문입니다. 프레임이 순서대로 도착한다는 보장이 없는 전송 환경에서, 5바이트만 더 붙이면 수신 쪽이 누락이나 순서 뒤바뀜을 나중에 점검할 수 있는 여지가 생깁니다.

3. 오디오 규격을 상수로 못박은 이유

프로토콜 헤더가 아무리 작아도, 프론트와 백엔드가 PCM 데이터의 의미를 다르게 해석하면 잡음만 나옵니다. 그래서 오디오 규격 자체를 코드 상수로 고정해 두고, 프론트·백엔드·오케스트레이터가 전부 이 값을 참조하게 만들었습니다.

SAMPLE_RATE = 16000  # Hz
CHANNELS = 1          # mono
SAMPLE_WIDTH = 2       # PCM16 = signed 16-bit
FRAME_MS = 20          # 프레임 길이 (ms)

FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000        # 320
FRAME_BYTES = FRAME_SAMPLES * SAMPLE_WIDTH * CHANNELS  # 640

16kHz·모노·PCM16·20ms 프레임이라는 조합이 곧 320샘플, 640바이트짜리 프레임이라는 뜻입니다. 이 숫자가 어느 한쪽에서 어긋나면(예를 들어 프론트가 48kHz로 캡처해 보내면) 서버는 소리를 빠르게 감기하거나 늘어뜨린 것처럼 잘못 해석합니다. 그래서 이 상수를 문서가 아니라 코드에 박아 두고, 양쪽이 같은 모듈을 참조하도록(또는 TS 쪽에 그대로 미러링하도록) 만든 것이 이 설계의 핵심입니다.

4. 브라우저 쪽 짝 — 캡처와 재생

서버 쪽 계약이 정해지면 브라우저 쪽은 그 규격에 맞추는 일만 남습니다. 마이크 캡처는 AudioWorklet(audioCapture.ts)으로 처리합니다. 메인 스레드를 막지 않는 별도 오디오 처리 스레드에서 마이크 입력을 20ms 단위로 잘라 PCM16으로 변환한 뒤 소켓에 실어 보내는 역할입니다. 재생은 Web Audio(audioPlayback.ts)가 맡아, 서버에서 들어오는 TTS_AUDIO 바이너리 프레임을 받는 대로 이어 붙여 재생합니다.

프론트 코드가 하는 일 자체는 단순합니다. 이미 정해진 헤더 포맷(타입 코드 1바이트 + 시퀀스 4바이트)과 오디오 규격(16kHz/mono/PCM16/20ms)만 그대로 지키면 됩니다. 어려운 결정은 전부 프로토콜 설계 단계에서 끝나 있고, 캡처·재생 코드는 그 계약을 충실히 따르기만 하면 되는 구조입니다.

5. JSON 쪽은 왜 굳이 따로 뒀나

오디오가 아닌 것은 전부 JSON 텍스트 프레임입니다. 대화록(TRANSCRIPT), 상태 이벤트(EVENT), 제어 명령(CONTROL)이 여기 해당합니다. 이런 데이터는 초당 50번씩 오가지 않기 때문에 JSON의 가독성과 확장성을 그대로 누리는 편이 유리합니다. 새 이벤트 타입을 추가하고 싶을 때 바이너리 헤더를 다시 설계할 필요 없이 필드 하나만 늘리면 됩니다.

def to_json(self) -> dict:
    out: dict = {"type": self.type.value}
    if self.text is not None:
        out["text"] = self.text
    if self.event is not None:
        out["event"] = self.event
    if self.meta is not None:
        out["meta"] = self.meta
    return out

TTS_AUDIO의 PCM은 이 JSON에 절대 실리지 않습니다. 오디오는 항상 바이너리 프레임으로만 이동한다는 원칙을 지켜야, 어느 프레임이 오디오이고 어느 프레임이 이벤트인지 수신 쪽에서 프레임 종류만 보고 즉시 분기할 수 있습니다.

자주 묻는 질문

Q. 오디오도 그냥 JSON에 base64로 실어서 보내면 안 되나요? 구조는 훨씬 단순해지지만 대가가 큽니다. base64는 원본보다 데이터 크기를 약 33% 늘리고, JSON 파싱 자체도 순수 바이너리 파싱보다 무겁습니다. 20ms마다, 초당 약 50번 오가는 오디오 경로에서는 이 오버헤드가 그대로 지연시간으로 쌓입니다. 반대로 초당 몇 번 안 오가는 이벤트·제어 메시지는 그 오버헤드가 무시할 만한 수준이라 JSON을 그대로 씁니다. 빈도가 다른 두 종류의 데이터에 같은 포맷을 강요하지 않은 것이 이 설계의 출발점이었습니다.

다음 편에서는 이 프로토콜 위에서 실제로 STT 결과가 어떻게 대화록으로 이어지는지, 텍스트 프레임이 오가는 흐름을 다뤄 보겠습니다.

음성봇 직접 만들기 시리즈 (전체 6편)

댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기