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

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

음성봇 "끼어들기"는 어떻게 처리할까 — 재생 중인 TTS를 실시간으로 끊는 법

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

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

사람과 대화할 때 상대가 말하는 도중에 끼어드는 건 자연스러운 일입니다. 그런데 음성봇을 직접 만들어 보면, 이 당연한 동작이 꽤 까다로운 동시성 문제라는 걸 알게 됩니다. 봇이 한창 답을 음성으로 재생하고 있는데 사람이 다시 말을 걸면, 재생 중인 오디오를 그 자리에서 멈추고 새로 들어온 말을 받아야 합니다. 이 글은 직접 만든 음성봇에서 이 "끼어들기(barge-in)"를 어떻게 처리했는지 정리한 기록입니다.

1. 왜 그냥 멈추면 안 되나

가장 단순하게 생각하면 "새 발화가 들리면 재생을 멈춘다"로 끝날 것 같지만, 실제로는 몇 가지가 동시에 맞물립니다.

봇의 답변 음성은 텍스트를 다 만든 뒤 한 번에 재생하는 게 아니라, 합성되는 대로 조각조각(청크) 흘려보내며 재생합니다. 그래야 답이 길어도 첫 소리가 빨리 나옵니다. 그런데 이 스트리밍 재생이 백그라운드에서 계속 돌고 있는 동안, 마이크로 들어오는 사람 목소리도 동시에 감시해야 합니다. 두 흐름이 동시에 진행되는 상황에서 한쪽이 다른 쪽을 멈추게 만드는 것이 끼어들기의 핵심입니다.

여기다 재생을 멈출 때 리소스를 깔끔하게 정리하지 않으면, 음성 합성기가 물고 있던 내부 버퍼나 태스크가 계속 살아남아 다음 턴에서 이상 동작을 일으킵니다. 멈추는 것 자체보다 "제대로 멈추고 뒷정리까지 끝내는 것"이 진짜 문제였습니다.

2. 재생을 별도 태스크로 띄운다

재생 로직을 만들 때 첫 번째 결정은 "재생을 기다리지 않는다"는 것이었습니다. 음성 합성 결과를 받아 청크 단위로 흘려보내는 함수를 만들고, 이걸 별도의 비동기 태스크로 백그라운드에 띄웁니다.

# 스트림의 첫 청크만 직접 기다려 지연시간을 측정하고,
# 나머지는 백그라운드 태스크가 넘겨받아 계속 전송한다
self._playback_task = asyncio.create_task(self._forward_playback(gen))
return first_chunk_ms

마이크에서 프레임이 들어오는 걸 처리하는 함수는 이 재생 태스크를 기다리지(await) 않습니다. 재생은 재생대로 흘러가고, 마이크 프레임을 받는 쪽은 그와 무관하게 계속 다음 프레임을 처리할 수 있습니다. 이렇게 분리해 두지 않으면, 봇이 말하는 동안에는 마이크 입력을 아예 못 받는 상황이 됩니다.

3. 끼어들기 신호가 오면 태스크를 취소한다

사람이 다시 말을 시작한 걸 감지하면(음성 구간 검출로 판단합니다), 재생 중이던 태스크를 취소합니다. 여기서 순서가 중요합니다.

async def _barge_in(self) -> None:
    task = self._playback_task
    if task is None or task.done():
        self._playback_task = None
        return

    await self._tts.stop()      # 1. 합성기에게 그만 만들라고 신호
    task.cancel()                # 2. 전송 태스크를 취소
    try:
        await task                # 3. 취소가 실제로 끝날 때까지 기다림
    except asyncio.CancelledError:
        pass
    finally:
        self._playback_task = None

먼저 음성 합성기 자체에 "그만 만들어도 된다"는 신호를 보내고, 그다음 전송 태스크를 취소하고, 마지막으로 그 취소가 실제로 끝날 때까지 기다립니다. task.cancel()만 부르고 결과를 기다리지 않으면, 태스크가 취소되는 도중에 하던 일(예: 소켓 전송)이 어중간하게 남을 수 있습니다. await task로 취소 완료를 확인해야 다음 턴을 안전하게 시작할 수 있습니다.

4. 취소당하는 쪽도 대비해야 한다

여기서 놓치기 쉬운 절반이 있습니다. 취소를 "거는" 쪽만 잘 짜도, 취소를 "당하는" 쪽이 대비돼 있지 않으면 소용없습니다. 청크를 전송하던 백그라운드 태스크는 자신이 언제 취소될지 모르기 때문에, 취소 신호를 받았을 때 자원을 정리하는 코드를 직접 갖고 있어야 합니다.

try:
    async for ev in gen:
        if ev.type == "SPEAK_AUDIO" and ev.audio:
            await send(ev.audio)
            await asyncio.sleep(0)   # 다음 끼어들기가 끼어들 틈을 준다
except asyncio.CancelledError:
    await gen.aclose()   # 합성기 쪽 제너레이터도 정리
    raise                 # 취소는 삼키지 않고 그대로 전달

await asyncio.sleep(0)을 청크 사이에 넣은 것도 의도적입니다. 이게 없으면 전송 루프가 한 번 시작된 뒤 끝까지 제어권을 놓지 않아서, 그사이 들어온 취소 요청이 반영될 틈이 없습니다. 아주 짧은 양보 한 번이 "말하는 도중에도 끊길 수 있는" 반응성을 만들어 줍니다.

자주 묻는 질문

Q. 재생 함수에 멈춤 플래그만 두는 방법도 있지 않나요? 플래그로 "멈춰야 함"을 체크하는 방식도 가능하지만, 그러면 체크하는 코드를 직접 여러 곳에 심어야 하고, 체크 사이 구간에서는 여전히 멈추지 않습니다. asyncio 태스크 취소를 쓰면 파이썬 런타임이 다음 await 지점에서 자동으로 CancelledError를 던져 주기 때문에, 멈춰야 할 곳을 일일이 챙기지 않아도 됩니다.

Q. 봇이 말하는 도중(음성 합성 전, 답을 생각하는 중)에는 끼어들 수 없나요? 지금 구조에서는 마이크 프레임을 처리하는 함수가 "이번 턴 처리 전체"를 기다리는 동안에는 다음 프레임을 못 받습니다. 그래서 재생이 시작되기 전, 답을 만드는 단계에서는 끼어들기가 반영되지 않습니다. 턴 처리를 별도 태스크로 돌리고 마이크 프레임 수신은 계속 살려 두는 방식으로 개선할 수 있다는 걸 인지하고 있고, 다음 과제로 남겨 뒀습니다.

댓글

이 블로그의 인기 게시물

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

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

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