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

161 lines
10 KiB
Markdown

# 로컬 음성 스택 결정 — 노트북 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.py``environment != 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_provider``openai|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.py``LocalWhisperStreamingSession` — 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 허용목록**으로 바꿨다.
```python
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)는 이 결정과 무관하게 그대로 남아 있다.