WebSocket으로 들어오는 오디오 청크를 버퍼링 없이 실시간 전사하는 파이프라인. 수신(WS) → 포맷 변환 → Silero VAD 발화 분할 → STT 워커 큐 → 결과 큐 → Redis 브로드캐스트 / DB 저장 의 비동기 흐름으로 동작하며, 무거운 STT 추론이 async 이벤트 루프를 막지 않도록 ThreadPoolExecutor로 오프로딩했다.
WebSocket 양방향
STT 워커 3
Silero VAD
2단 큐
종료 플러시
청크 수신 + 결과 송신
ThreadPoolExecutor
16kHz·32ms·임계 0.5
입력 / 결과 분리
잔여 버퍼 패딩
왜 이 설계가 필요했는가 (Problem)
처리 흐름
핵심 설계 결정
1. 입력/결과 큐 분리 + ThreadPoolExecutor 오프로딩
# STT 워커 — 입력 큐에서 꺼내 스레드풀에서 추론세그먼트 = await 입력 큐.get()텍스트 = await 이벤트 루프.run_in_executor( 스레드풀, # ThreadPoolExecutor · 워커 3개 세그먼트 전사, # Whisper 추론 (블로킹) 세그먼트, 직전 문맥 프롬프트,)await 결과 큐.put({텍스트, 세그먼트 번호, 시각})
2. Silero VAD 상태머신 발화 분할
# 프레임마다 음성 확률 판정 → 발화가 끝나면 세그먼트 반환음성 확률 = VAD 모델(프레임, 16kHz)if 음성 확률 >= 임계값: # 0.5 발화 버퍼에 프레임 추가; 음성 프레임 수 += 1elif 발화 중 and 침묵 프레임 수 >= 최대 침묵: # 20프레임 return 발화 버퍼 전체 # 발화 종료 → 세그먼트 반환
3. 포맷별 디코딩 전략
# 디코딩 전략 선택if 입력 포맷 in [PCM, raw]: return NumPy 직처리if 입력 포맷 in [Opus, WebM] and 스트리밍 포맷: return PyAV 파서return Pydub # MP3 등 폴백
4. 연결 종료 시 버퍼 플러시
# 연결 종료 처리남은 오디오 = 변환기.flush()if 남은 오디오 and 길이 < 프레임 크기: 남은 오디오 += 0 바이트 × (프레임 크기 - 길이) # 패딩파이프라인에 남은 오디오 투입 # 강제 전사최종 결과 = await STT 워커 완료 대기
설계가 만든 결과 (Result)
검증 결과
발화 분할 상태머신 테스트 · 17/17 통과 (pytest + hypothesis)
검증 내용
방식
무음만 들어오면 세그먼트가 한 번도 나오지 않는다
hypothesis 300 케이스 (임의 길이)
최소 발화길이(5프레임) 미달은 침묵이 와도 버려진다
hypothesis 200 케이스
연속 음성 5프레임에 도달해야 발화로 인정된다
경계
침묵 20프레임 도달 시 세그먼트 반환 · 19프레임까지는 반환 안 함
경계 양방향
세그먼트 반환 후 상태 완전 초기화 (모델 내부 상태 포함)
—
프레임 크기가 다르면 무시하고 상태를 바꾸지 않는다
5가지 크기
종료 플러시는 최소길이 조건을 무시하고 남은 버퍼를 반환한다
종료 유실 방지
민감도 설정 3 이상 → 임계값 0.6 상향
설정 변환
모델은 스텁, 상태머신은 실물로 테스트했다
테스트를 짜다 드러난 구조 문제
부수적으로 의존성 목록에 Gemini SDK 패키지가 누락된 것도 확인했다 (코드는 그 SDK 를 임포트한다). 도커 이미지에서만 우연히 해결되고 있었다.
이 검증이 말하지 않는 것
배포 환경 실측 (2026-08-30)
자막 지연 — 단일 세션
발화 구간
길이
자막 지연
7.39 → 12.83s
5.44s
~5.2s
13.02 → 18.94s
5.92s
~5.1s
19.33 → 25.31s
5.98s
~5.7s
25.60 → 29.98s
4.38s
~6.0s
지연은 추론에서 오지 않는다
구간
실측/추정
비중
STT 추론
발화길이 × RTF 0.15 ≈ 0.7~0.9s
~15%
VAD 침묵 확정
세그먼트 간 공백 실측 0.19~0.39s
~5%
🔴 미규명
~4s
~75%
추론은 실시간의 6.7배 빠른데(RTF 0.15) 자막은 5.5초 늦는다. §1 이 주장한 오프로딩은 이벤트 루프를 막지 않는 데는 성공했지만, 지연의 대부분은 그 바깥에 있다.