vignette/docs/decisions/local-voice-stack.md
2026-08-09 18:22:03 +09:00

10 KiB

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

Date: 2026-08-08 (같은 날 TTS 재선정으로 개정) Status: accepted — STT·TTS 모두 구현·실측 완료, 라이선스 제약 없음 Owner decision: 윤찬

결정

음성 캐스케이드의 양쪽을 개발 노트북에 상주하는 로컬 모델로 운영한다. 둘 다 허용적 라이선스(MIT) 라 운영 사용에 제약이 없다.

  • STT(듣기): faster-whisper — MIT (local_whisper provider)
  • TTS(말하기): MeloTTS Korean — MIT (melotts provider)

TTS 재선정 (Higgs → MeloTTS)

처음에는 노트북에 이미 있던 Higgs Audio v3 TTS 4B를 쓰기로 했으나, 그 모델은 연구/비상업 라이선스config.pyenvironment != dev에서 차단하고 있었다. 그 가드를 제거하는 건 법적 판단이라 코드로 결정할 수 없었다. 그래서 상업 사용이 허용된 설치형을 다시 찾았고 MeloTTS로 바꿨다. 결과적으로 가드를 건드릴 필요 자체가 사라졌다 — Higgs 가드는 그대로 두고 provider 만 melotts로 두면 운영에서도 동작한다.

검토한 대안과 탈락 이유:

후보 라이선스 한국어 판정
MeloTTS MIT 지원 채택. CPU 실시간, 사전학습 다화자
Kokoro-82M Apache 2.0 미지원 탈락. 공식 VOICES.md 언어 목록에 한국어 없음
Piper GPL 제한적 탈락. 상업 배포에 부담
XTTS-v2 / Fish Speech 비상업 지원 탈락. Higgs 와 같은 문제
Higgs Audio v3 4B 연구/비상업 지원 보류. dev 전용 유지

MeloTTS는 사전학습 다화자 모델이라 실존 인물 음성 reference 를 전혀 쓰지 않는다. Higgs 경로가 P1 프리셋 한정이었던 이유(권리 안전한 synthetic reference 를 P1만 보유)가 MeloTTS 에는 없으므로 모든 페르소나 프리셋에 적용된다.

이 결정이 뒤집는 것

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.
  • scripts/melotts-server.py — loopback HTTP 사이드카(/health, POST /tts → WAV). VIGNETTE_VOICE_TTS_PROVIDER=melotts + VIGNETTE_MELOTTS_TTS_URL 설정. 실행: scripts/start-melotts.ps1.
  • 공개 런처 scripts/start-public-runtime.ps1은 현재 CPU 운영값인 local_whisper/small/cpu-int8(9882)과 melotts/melotts-korean(9883)을 직접 소유한다. scripts/probe-public-voice-sidecars.py가 Whisper의 첫 WebSocket ready 프레임과 MeloTTS /health의 provider/model/license/reference metadata를 정확히 검증한다. 기존 포트 리스너가 있어도 이 계약을 통과하지 않으면 임의 종료·재사용하지 않고 API 재시작 전에 fail-closed한다.
  • 공개 API는 같은 provider/model을 /voice/health로 다시 증명해야 기존 프로세스를 유지한다. 새 Uvicorn은 --ws websockets --ws-max-queue 4로 고정한다. 이 항목은 런처 소스 계약이며, 실제 공개 적용 완료 증거는 authenticated WSS·동의 기반 50분 soak·동시간 topology/runtime 증거가 모두 통과한 뒤 G7 pack에 남긴다.
  • G7 실제 증거 승격은 -RequireFreshPublicProvenance로 detached-clean commit/tree와 Python/cloudflared/config SHA를 mutation 전에 검증한다. legacy API와 exact-config cloudflared를 bounded 교체한 뒤 새 PID/start/executable·command SHA/실제 cwd의 safe receipt를 남기며 raw command line·config contents는 저장하지 않는다. runner는 최소 3,120초를 캡처하고 voice/runtime/topology 공통 3,000초를 canonical checker와 결속한다.

실측 증거 (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이다.

MeloTTS 실측 증거 (2026-08-08)

설치: 전용 venv C:\Users\encep\.venvs\vignette-melotts, melotts 0.1.2. 설치 중 걸린 것 3가지와 해법을 남긴다(다음 사람이 같은 데서 막힌다).

  1. librosa 0.9.1(MeloTTS 핀)이 pkg_resources 를 쓰는데 설치가 올린 setuptools 83 에서 제거됐다 → pip install "setuptools<81".
  2. MeCab 이 일본어 unidic 사전을 요구한다(MeloTTS 가 언어와 무관하게 임포트) → python -m unidic download.
  3. 한국어 g2p(g2pkk)가 Windows 에서 eunjeon 을 요구한다 → pip install eunjeon.

성능(CPU, torch 2.13.0+cpu, GPU 미사용):

회차 텍스트 길이 오디오 길이 합성 시간 RTF
1 (웜업) 22자 4.78s 3.89s 0.81
2 13자 2.99s 0.82s 0.28
3 31자 6.16s 1.65s 0.27

정상 상태 RTF 0.27~0.28 로 실시간의 약 3.6배 빠르다. 첫 실행의 13.25는 모델 다운로드가 섞인 값이다.

왕복 검증 — 로컬 TTS 로 합성한 음성을 로컬 STT 로 되돌렸다. 둘 다 노트북 안에서만 돈다.

입력  그렇게 느끼셨군요. 조금 더 이야기해 주실 수 있을까요?
전사  그렇게 느끼셨군요. 조금 더 이야기해 주실 수 있을까요?   (완전 일치, word timestamp 8개)

HTTP 엔드포인트 POST /tts 실측: 200, WAV 350,566 bytes, 3.61s, 헤더 X-Vignette-TTS-Provider: melotts / Model: melotts-korean / License: MIT. 빈 텍스트 422, 알 수 없는 경로 404 로 fail-closed. 이 응답 WAV 도 왕복 전사에서 완전 일치했다.

회귀: 사이드카 16/16, API 전체 914 passed, 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.py는 계속 environment != dev에서 VIGNETTE_VOICE_TTS_PROVIDER=higgs를 차단한다. MeloTTS 채택으로 이 가드를 풀 이유가 없어졌다. Higgs 를 굳이 운영에서 쓰고 싶어지면 그때 라이선스 근거를 이 문서에 추가하고 가드를 조정한다.
  3. CPU 폴백 성능. CPU int8에서는 small이 현실적이다. large-v3는 CPU에서 실시간 스트리밍에 맞추기 어렵다.

G7 게이트에 미친 영향

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

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

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