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

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

음성봇에게 "말이 끝났다"를 어떻게 알려줄까 — VAD 엔드포인터 설계

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

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

음성봇이 "사람이 말을 끝냈다"는 걸 대체 어떻게 알아챌까요? 사람은 상대의 말투, 침묵의 길이, 문맥을 종합해 직관적으로 판단하지만, 마이크에서 들어오는 건 그냥 숫자로 된 오디오 샘플뿐입니다. 이 판단을 놓치면 문제는 양쪽으로 다 생깁니다. 너무 성급하게 "끝났다"고 판단하면 사람이 숨 고르며 잠깐 멈춘 걸 끝난 것으로 착각해 말을 자르게 되고, 너무 오래 기다리면 봇이 굼떠 보입니다. 이 판단을 담당하는 조각을 엔드포인터(endpointer)라고 부르는데, 이번 편은 이걸 직접 설계한 기록입니다.

1. 왜 에너지 임계값이 "일단은" 정답인가

발화가 끝났는지 판단하려면 먼저 프레임 하나하나가 "말소리인지 아닌지"부터 판정할 수 있어야 합니다. 이 판정을 제대로 하려면 원래는 신경망 기반 음성 감지 모델을 써야 하지만, 처음부터 그걸 붙이면 판정 로직 자체가 맞게 동작하는지 검증하기가 오히려 어려워집니다. 그래서 첫 버전은 훨씬 단순한 방법을 택했습니다. 오디오 프레임의 RMS(제곱평균제곱근) 에너지를 구해서, 일정 임계값을 넘으면 말소리로 판정하는 방식입니다.

DEFAULT_ENERGY_THRESHOLD: float = 500.0

def _is_speech(self, frame: bytes) -> bool:
    return self._rms(frame) > self.energy_threshold

PCM16 오디오는 샘플 값이 -32768에서 32767 사이를 오갑니다. 그 풀스케일 대비 500이라는 값은 약 1.5% 수준으로, 마이크 잡음이나 DC 오프셋 같은 진짜 무음의 낮은 에너지보다는 확실히 높고, 실제 대화 음성의 에너지보다는 한참 낮게 잡아서 양쪽 모두에 여유를 두는 값입니다. 물론 이 방식은 소음과 말소리를 구분하지 못한다는 뚜렷한 한계가 있습니다. 하지만 지금 단계에서 중요한 건 정확도가 아니라, 판정 로직 하나를 갈아 끼울 수 있는 구조를 먼저 만드는 것이었습니다.

2. idle과 speaking, 두 상태로 충분한 이유

프레임 단위 판정이 있어도, 그것만으로는 "말이 끝났다"를 말할 수 없습니다. 프레임 하나가 무음이라고 바로 끝난 게 아니라, 문장 중간의 숨 고르기일 수도 있기 때문입니다. 그래서 엔드포인터는 프레임을 하나씩 볼 뿐 아니라, 지금 발화가 진행 중인지 아닌지를 상태로 들고 있습니다. idle(대기)과 speaking(발화 중) 두 상태만으로 충분합니다.

idle 상태에서 말소리 프레임이 들어오면 speaking으로 전이하면서 speech_start 이벤트를 냅니다. speaking 상태에서는 무음 프레임이 연속으로 쌓이는 걸 세다가, 그 연속 무음이 일정 길이(기본 500ms)에 도달하면 endpoint 이벤트를 내고 idle로 돌아갑니다. 이 사이에 말소리 프레임이 다시 들어오면 무음 카운트는 그 자리에서 리셋됩니다. 즉 짧은 침묵은 발화를 끊지 않고, 충분히 긴 침묵만 발화 종료로 인정하는 구조입니다. 상태는 이 두 개뿐이고, 이벤트도 speech_start와 endpoint 두 개뿐이라, push 한 번의 반환값은 항상 둘 중 하나이거나 빈 리스트입니다.

3. 500ms를 프레임 개수로 바꾸는 계산

엔드포인터에 들어오는 오디오는 20ms짜리 프레임 단위입니다. 그런데 사람이 다루는 값은 "500밀리초 침묵"처럼 시간 단위입니다. 이 둘을 잇는 작업이 필요한데, 매 프레임마다 누적 시간을 실수로 더해가며 비교하는 대신, 처음부터 "몇 프레임이 필요한가"를 정수로 미리 계산해 둡니다.

self._silence_frames_needed = max(
    1, -(-silence_ms // frame_ms)  # ceil(silence_ms / frame_ms)
)

여기서 나눗셈을 그냥 버림(floor)으로 하지 않고 올림(ceil)으로 하는 이유가 있습니다. 만약 500ms를 20ms로 나눈 값이 딱 떨어지지 않는 조합이라면, 버림 나눗셈은 필요한 것보다 적은 프레임 수를 요구하게 되어 실제로는 지정한 침묵 시간보다 짧게 끊고 끝났다고 판단해버립니다. 반대로 올림 나눗셈은 "최소한 이만큼의 침묵은 반드시 채워야 한다"는 조건을 지켜줍니다. 나눗셈 결과가 0이 되는 극단적인 경우를 막기 위해 최소 1프레임은 보장하도록 max(1, ...)도 함께 걸어 두었습니다. 이렇게 구한 정수 하나만 카운터와 비교하면 되므로, push가 호출될 때마다 매번 시간 계산을 반복할 필요가 없습니다.

4. Silero로 바꿀 자리와, 바뀌지 않을 자리

이 구조를 짜면서 처음부터 염두에 둔 건, 지금의 에너지 임계값 판정이 영원히 이대로 갈 게 아니라는 점이었습니다. 나중에 실제 신경망 기반 음성 감지 모델(Silero VAD 같은)로 교체할 것을 가정하고, 그 교체가 딱 한 지점만 건드리면 끝나도록 설계했습니다. 바로 _is_speech 함수입니다. 이 함수가 지금은 RMS 값과 임계값을 비교하지만, 나중에는 프레임을 모델에 넣어 얻은 발화 확률과 임계값을 비교하는 식으로 내부만 바뀌면 됩니다.

반대로 절대 바뀌지 않아야 할 부분도 명확히 갈라 두었습니다. idle과 speaking으로 이루어진 상태머신, speech_start와 endpoint라는 이벤트 어휘, 그리고 이 이벤트를 소비하는 오케스트레이터 쪽 계약입니다. 판정 알고리즘이 바뀌어도 이 골격이 그대로 유지되어야, 나중에 모델을 바꾸는 작업이 국소적인 교체로 끝나고 시스템 전체를 다시 짜는 일이 되지 않습니다. 또 하나 분명히 해 둔 경계는, 엔드포인터가 오디오 자체를 들고 있지 않는다는 점입니다. speech_start부터 endpoint까지의 오디오를 버퍼링하는 일은 이 클래스의 책임이 아니라, 이벤트를 받아 쓰는 오케스트레이터 쪽의 몫으로 남겨 두었습니다.

에너지 임계값이라는 단순한 판정 하나에서 시작했지만, 그 판정을 감싸는 상태머신과 이벤트 어휘, 그리고 시간을 프레임 수로 바꾸는 계산까지 하나씩 뜯어보면 각자 이유가 있는 결정들이었습니다. 다음 편에서는 이 엔드포인터가 낸 이벤트를 오케스트레이터가 실제로 어떻게 받아서, 턴을 넘기고 다음 단계로 넘어가는지를 다뤄보겠습니다.

댓글

이 블로그의 인기 게시물

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

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

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