로컬 Whisper STT를 음성봇에 연결하기 — faster-whisper 연동기
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다.
- 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법
- 2. "말이 끝났다"는 판단 — VAD 엔드포인터
- 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계
- 4. 로컬 LLM 대화 두뇌와 도구 호출 루프
- 5. 매 턴 지연시간을 재는 이유
- 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜
- 7. 로컬 Whisper STT 연결하기 — 지금 보는 글
- 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결)
지난 편에서 음성봇의 STT 자리에 스텁을 꽂아 넣었습니다. 스텁은 정해진 문장을 그대로 돌려주는 가짜 귀였습니다. 오케스트레이터가 이벤트를 제대로 받아 다음 단계로 넘어가는지, 끼어들기가 타이밍대로 처리되는지를 확인하기엔 충분했습니다. 그런데 스텁만으로는 절대 알 수 없는 것이 하나 있었습니다. 진짜 사람 목소리를 실제로 텍스트로 바꿀 수 있느냐는 질문입니다. 이번 편은 그 자리에 faster-whisper 기반의 진짜 STT 엔진을 연결한 과정을 정리합니다.
인터페이스 계약만 지키면 엔진 교체는 무수정이다
50편에서 STT 엔진이 지켜야 할 추상 인터페이스를 정의했습니다. 핵심은 recognize 함수가 EngineEvent 스트림을 내보낸다는 계약 하나였습니다. 오디오가 없으면 NO_INPUT_TIMEOUT, 음성이 들어오기 시작하면 START_OF_INPUT, 인식이 끝나면 텍스트를 담은 RECOGNITION_COMPLETE를 순서대로 방출합니다.
WhisperSTT는 이 계약을 그대로 구현했습니다. 오케스트레이터와 웹소켓 계층은 STT가 스텁인지 실제 모델인지 전혀 모릅니다. 그저 이 세 가지 이벤트 이름과 순서만 믿고 동작합니다. 그래서 스텁을 걷어내고 WhisperSTT를 꽂는 작업은 오케스트레이터 쪽 코드를 단 한 줄도 건드리지 않고 끝났습니다. 인터페이스를 먼저 설계해두는 이유가 바로 이것이었습니다. 구현체는 갈아 끼워도 계약을 지키는 한 나머지 시스템은 안전합니다.
PCM 프레임에서 텍스트가 나오기까지
recognize는 먼저 오디오 소스에서 PCM16 프레임을 전부 모읍니다. 동기 이터러블과 비동기 이터레이터를 둘 다 받을 수 있게 __aiter__ 유무로 분기해서 처리합니다. 프레임을 다 모았는데 바이트가 하나도 없으면 모델을 아예 부르지 않고 NO_INPUT_TIMEOUT만 내보내고 끝냅니다. 음성이 없는 턴에 모델을 돌리지 않는 이 분기가 리소스 낭비를 막는 첫 번째 방어선입니다.
오디오가 있으면 START_OF_INPUT을 먼저 내보낸 다음, 리틀엔디안 int16 바이트를 float32로 변환합니다. 32768로 나누는 이유는 가장 작은 음수 샘플이 정확히 -1.0에 맞도록 하기 위해서입니다. 변환이 끝나면 asyncio.to_thread로 실제 transcribe 호출을 별도 스레드에 던집니다. faster-whisper의 트랜스크라이브 자체가 블로킹, CPU 바운드 연산이라 이벤트 루프에서 그대로 돌리면 동시에 처리 중인 다른 세션이 멈춰버립니다. 스레드로 빼는 한 줄이 없었다면 세션이 하나만 있어도 서버 전체가 버벅였을 것입니다. 결과 텍스트가 나오면 RECOGNITION_COMPLETE에 실어 내보내는 것으로 흐름이 끝납니다.
모델 로딩을 뒤로 미루는 설계
WhisperModel을 생성자에서 바로 만들지 않은 것도 의도적인 선택입니다. 모델은 model이나 transcribe_fn 인자로 주입받을 수 있고, 둘 다 안 주면 첫 recognize 호출 시점까지 로딩을 미룹니다. 실제 faster-whisper import 문도 이 지연 로딩 경로 안에 숨어 있어서, 패키지가 없는 환경에서 이 클래스를 그냥 생성만 하는 코드는 아무 문제 없이 돌아갑니다.
이 구조 덕분에 테스트에서는 진짜 모델 대신 가짜 transcribe_fn을 주입해서 오디오 수집, 변환, 이벤트 순서 로직만 따로 검증할 수 있습니다. 모델 다운로드도, GPU도, 수백 메가바이트짜리 가중치도 필요 없습니다. 의존성 주입 하나로 무거운 부분과 가벼운 부분을 완전히 분리해둔 셈입니다.
빠른 테스트와 느린 테스트를 가르는 기준
여기서 테스트를 두 층으로 나눴습니다. 하나는 faster-whisper 패키지 자체가 없을 수도 있다는 문제입니다. pytest.importorskip("faster_whisper")를 테스트 상단에 넣어두면 패키지가 설치돼 있지 않은 환경에서는 해당 테스트를 건너뜁니다. 실패로 처리하지 않고 스킵으로 처리하는 것이 핵심입니다. 설치 여부가 코드 품질과는 무관한 별개의 문제이기 때문입니다.
다른 하나는 패키지는 있어도 실제 모델을 로드하는 테스트는 느리다는 문제입니다. 모델을 내려받고 메모리에 올리는 것만으로도 수 초에서 수십 초가 걸립니다. 이런 테스트에는 @pytest.mark.slow 마커를 붙여서 평소 돌리는 빠른 테스트 스위트에서는 제외했습니다. 커밋할 때마다 매번 무거운 모델을 로드하고 있으면 개발 루프 자체가 느려져서 테스트를 점점 안 돌리게 됩니다. 인터페이스 계약, 즉 이벤트 어휘와 순서는 가짜 함수로 매번 빠르게 검증하고, 모델 정확도처럼 실제 무게가 실리는 부분은 필요할 때만 별도로 확인하는 전략입니다. 두 층을 분리해두니 평소 개발 루프는 가볍게 유지되고, 정확도 검증이 필요한 순간에만 느린 테스트를 골라서 돌리면 됩니다.
마무리
async def recognize(self, audio_iter: AudioSource):
pcm = await self._collect_pcm(audio_iter)
if len(pcm) == 0:
yield EngineEvent("NO_INPUT_TIMEOUT")
return
yield EngineEvent("START_OF_INPUT")
audio = self._pcm16_to_float32(pcm)
transcribe_fn = self._ensure_transcribe_fn()
text = await asyncio.to_thread(transcribe_fn, audio)
yield EngineEvent("RECOGNITION_COMPLETE", text=text)
스텁이 오케스트레이터 로직을 검증하는 자리였다면, WhisperSTT는 그 로직 위에 실제 인식 능력을 얹는 자리였습니다. 인터페이스를 먼저 굳혀놓은 덕분에 이 교체가 조용히 끝났다는 것 자체가 이번 작업의 소득입니다. 다음 편에서는 STT가 내놓은 텍스트를 받아 응답을 만드는 두뇌 플러그인 쪽을 다룹니다.
FAQ
Q1. faster-whisper 패키지가 설치돼 있지 않으면 테스트가 실패하나요?
아닙니다. 테스트 상단에서 pytest.importorskip("faster_whisper")를 호출해두면 패키지가 없는 환경에서는 해당 테스트가 실패가 아니라 스킵으로 처리됩니다. 패키지 설치 여부와 로직 정확성은 별개의 문제이기 때문에 이렇게 구분해뒀습니다.
Q2. 느린 테스트는 아예 안 돌리는 건가요?
아닙니다. @pytest.mark.slow로 표시만 해두고 평소 빠른 스위트에서 제외했을 뿐, 실제 모델 정확도를 확인해야 하는 시점에는 그 마커가 붙은 테스트만 골라서 따로 실행합니다. 자주 도는 검증과 가끔 도는 검증을 마커 하나로 나눠둔 것입니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기