vignette/docs/decisions/local-voice-stack.md
Yun Chan 05aa7b312e 로컬 whisper STT 사이드카 디바이스 폴백과 deprecated audioop 제거
cuDNN 부재 환경에서 ctranslate2 는 Python 예외가 아니라 네이티브 크래시로 죽어
프로세스가 통째로 사라진다. 같은 프로세스의 try/except 로는 절대 잡을 수 없어
디바이스 확인을 버릴 수 있는 자식 프로세스(--self-check)로 분리했다.
auto 는 CUDA 프로브 실패 시 CPU(int8)로 폴백하고, 명시적 --device cuda 는 조용히
강등하지 않는다.

함께 고친 것:
- warmup PCM 생성의 연산자 우선순위 버그(b"\x00\x00" * N // 2 가 바이트열 정수
  나눗셈이 되어 TypeError). warmup_pcm() 으로 분리하고 테스트로 고정했다.
- audioop 은 Python 3.13 에서 제거되므로 array + 정수 연산으로 RMS 를 직접 구한다.

실측(저장소 무참조 synthetic seed 8.72초, CPU int8 small):
interim 9 · final 5 · word timestamp 12개 present, 전사는 원문과 한 글자 차이.
사이드카 회귀 37/37.
2026-08-08 01:39:58 +09:00

5.7 KiB

로컬 음성 스택 결정 — 노트북 faster-whisper STT + Higgs TTS

Date: 2026-08-08 Status: accepted (STT 구현 완료 · TTS 운영 사용은 라이선스 확인 대기) Owner decision: 윤찬

결정

음성 캐스케이드의 양쪽을 개발 노트북에 상주하는 로컬 모델로 운영한다.

  • STT(듣기): faster-whisper (local_whisper provider)
  • TTS(말하기): Higgs Audio v3 TTS 4B

이 결정이 뒤집는 것

MASTERPLAN.md의 음성 행은 "OpenAI gpt-4o-mini-tts + Deepgram STT, voice_id 추상화(Higgs/GPT-SoVITS 로컬 폴백)"였다. 그 기록에서 로컬 모델은 폴백이었고 주 경로는 외부 API였다. 이 문서가 그 행을 대체한다.

왜 이 결정이 필요했는가

2026-08-08 확인 결과 상태가 서로 어긋나 있었다.

  • 실제 런타임은 Deepgram을 쓰고 있지 않았다. .env에 STT provider 설정이 없어 코드 기본값 openai (배치 전사)가 적용됐고, NAS 프리뷰도 VIGNETTE_VOICE_STT_PROVIDER=openai였다.
  • 그런데 G7 종료 체커(scripts/check-g7-external-proof.py)는 expected_stt_provider == "deepgram"하드코딩하고 있었다. 이는 결정 기록이 아니라 벤더 한 줄이었고, 그 때문에 "G7을 닫으려면 운영 Deepgram 키가 필요하다"는 잘못된 요구가 만들어졌다.
  • 코드에는 로컬 STT 경로가 아예 없었다. voice_stt_provideropenai|deepgram 둘뿐이었고, interim/final 스트리밍 구현은 Deepgram 전용이었다. Higgs는 TTS 전용이라 STT를 대신하지 못한다.

근거

  • 데이터 주권: 상담 훈련 오디오가 호스트를 벗어나지 않는다. 미성년 원본 활용동의·한신대 데이터 거버넌스 게이트가 열려 있는 상태에서 외부 STT로 원음을 보내는 것보다 경계가 단순하다.
  • 외부 키 의존 제거: 운영 Deepgram key/quota 없이도 G7의 interim/final 스트리밍 계약을 만족한다.
  • 자산 보유: 노트북에 faster-whisper 1.0.3 + ctranslate2 4.5.0과 모델 (large-v3/large-v3-turbo/medium/small/base)이 이미 캐시돼 있고, Higgs v3 4B도 ComfyUI에 있다.

구현

  • scripts/local-whisper-stt-server.py — loopback WebSocket 사이드카. Higgs TTS 서버와 같은 상주 모델 방식이다. linear16 PCM을 받아 RMS 기반 endpointing으로 발화를 나누고 interim/final과 word timestamp를 낸다. 오디오는 발화 단위 메모리 버퍼로만 다루고 확정 즉시 버린다. 디스크에 쓰지 않는다.
  • apps/api/app/services/voice.pyLocalWhisperStreamingSession — Deepgram 세션과 같은 공개 표면 (send_audio/finish/abort)이라 WebSocket 라우트는 provider로 분기하지 않는다.
  • VIGNETTE_VOICE_STT_PROVIDER=local_whisper + VIGNETTE_LOCAL_WHISPER_* 설정.
  • 실행: scripts/start-local-whisper-stt.ps1.

실측 증거 (2026-08-08)

저장소의 무참조 synthetic seed 음성(8.72초, 원문이 manifest에 기록됨)으로 종단 검증했다.

항목
interim 프레임 9
final 프레임 5 (발화 분절 동작)
word timestamp 12개 전부 present
전사 안녕하세여. 저는 서연이에요. 오늘은 천천히 너무 밝지 않게 하지만 또렷하게 말해 볼게요.
원문 안녕하세요. 저는 서연이에요. 오늘은 천천히, 너무 밝지 않게, 하지만 또렷하게 말해볼게요.

small + CPU int8에서 한 글자(안녕하세요안녕하세여) 차이였다. 회귀는 사이드카 37/37, API 전체 908 passed, gateway 58, G7 checker 23, ruff clean이다.

알려진 제약

  1. cuDNN 부재로 GPU 추론 불가. 이 노트북은 CUDA 장치가 보이지만 cudnn_ops64_9.dll이 없어 ctranslate2가 네이티브 크래시로 죽는다. Python 예외가 아니라 프로세스가 통째로 죽으므로 같은 프로세스의 try/except로는 잡을 수 없다. 그래서 디바이스 확인을 버릴 수 있는 자식 프로세스 (--self-check)로 분리하고 auto에서 CPU(int8)로 폴백한다. 명시적 --device cuda는 조용히 강등하지 않는다. cuDNN 9를 설치하면 GPU float16으로 올라가고 large-v3도 실시간권에 들어온다. 설치는 환경 변경이라 소유자 판단으로 남겼다.
  2. Higgs 운영 사용은 아직 막혀 있다. apps/api/app/config.pyenvironment != dev에서 VIGNETTE_VOICE_TTS_PROVIDER=higgs를 차단한다(Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는 법적 판단이라 코드에서 임의로 제거하지 않았다. 산학협력 연구·교육 용도가 라이선스 범위에 들어가는지 확인하고 그 근거를 이 문서에 추가한 뒤 가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는 OpenAI gpt-4o-mini-tts를 유지한다.
  3. CPU 폴백 성능. CPU int8에서는 small이 현실적이다. large-v3는 CPU에서 실시간 스트리밍에 맞추기 어렵다.

G7 게이트에 미친 영향

check-g7-external-proof.py의 벤더 하드코딩을 운영하기로 한 provider 허용목록으로 바꿨다.

ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram")
ALLOWED_TTS_PROVIDERS = ("higgs", "openai")

게이트를 약화시키지 않았다. 배치 STT(openai)는 interim/final 계약을 만족할 수 없어 목록에 없고, expected_* == ready_* 결속(선언한 provider와 실제로 돈 provider가 같아야 함)은 그대로다. G7의 나머지 세 artifact(물리 마이크 50분 soak, worker/topology high-water, 독립 blind human voice-gain pack)는 이 결정과 무관하게 그대로 남아 있다.