diff --git a/.env.example b/.env.example index d08b179..9d8266a 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,12 @@ VIGNETTE_VOICE_TTS_PROVIDER=openai VIGNETTE_HIGGS_TTS_URL=http://127.0.0.1:9881 VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS=300 +# `melotts`는 노트북 상주 MeloTTS 한국어 사이드카(MIT)라 운영에서도 쓸 수 있다. +# 사전학습 다화자 모델이라 실존 인물 reference 를 쓰지 않는다. +# 서버는 scripts/start-melotts.ps1 로 띄운다. +VIGNETTE_MELOTTS_TTS_URL=http://127.0.0.1:9883 +VIGNETTE_MELOTTS_TTS_TIMEOUT_SECONDS=120 + # STT. `local_whisper`는 노트북 상주 faster-whisper 사이드카를 쓴다. 외부 키가 필요 없고 # 오디오가 호스트를 벗어나지 않는다. 서버는 scripts/start-local-whisper-stt.ps1 로 띄운다. # `openai`는 배치라 interim 이 없고, `deepgram`은 운영 키가 필요하다. diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 9d0acaf..bbc4051 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -230,7 +230,19 @@ class Settings(BaseSettings): default="", validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR", ) - voice_tts_provider: Literal["openai", "higgs"] = Field( + # `melotts`는 MIT 라이선스 사전학습 한국어 다화자 모델이라 상업 사용 제약도, + # 실존 인물 reference 문제도 없다. Higgs 와 달리 환경·프리셋 제한이 필요 없다. + melotts_tts_url: str = Field( + default="http://127.0.0.1:9883", + validation_alias="VIGNETTE_MELOTTS_TTS_URL", + ) + melotts_tts_timeout_seconds: float = Field( + default=120.0, + ge=1.0, + le=600.0, + validation_alias="VIGNETTE_MELOTTS_TTS_TIMEOUT_SECONDS", + ) + voice_tts_provider: Literal["openai", "higgs", "melotts"] = Field( default="openai", validation_alias="VIGNETTE_VOICE_TTS_PROVIDER", ) diff --git a/apps/api/app/services/voice.py b/apps/api/app/services/voice.py index c8d3834..7793111 100644 --- a/apps/api/app/services/voice.py +++ b/apps/api/app/services/voice.py @@ -42,6 +42,8 @@ OPENAI_BASE_URL = "https://api.openai.com/v1" STT_ENDPOINT = "/audio/transcriptions" TTS_ENDPOINT = "/audio/speech" HIGGS_TTS_ENDPOINT = "/tts" +MELOTTS_TTS_ENDPOINT = "/tts" +MELOTTS_TTS_MODEL = "melotts-korean" DEEPGRAM_STT_URL = "wss://api.deepgram.com/v1/listen" DEEPGRAM_STT_MODEL = "nova-3" @@ -919,6 +921,8 @@ class VoiceService: local_whisper_utterance_end_ms: Optional[int] = None, local_whisper_finalize_timeout_seconds: Optional[float] = None, local_whisper_connect: Optional[Callable[..., Awaitable[Any]]] = None, + melotts_base_url: Optional[str] = None, + melotts_timeout_seconds: Optional[float] = None, ) -> None: self._api_key = (api_key if api_key is not None else settings.openai_api_key) or "" self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/") @@ -1025,6 +1029,20 @@ class VoiceService: ), ) self._local_whisper_connect = local_whisper_connect or websocket_connect + self._melotts_base_url = ( + melotts_base_url + if melotts_base_url is not None + else settings.melotts_tts_url + ).rstrip("/") + self._melotts_timeout_seconds = max( + 1.0, + float( + melotts_timeout_seconds + if melotts_timeout_seconds is not None + else settings.melotts_tts_timeout_seconds + ), + ) + self._melotts_client: httpx.AsyncClient | None = None self._tts_provider = ( tts_provider if tts_provider is not None else settings.voice_tts_provider ).strip().lower() @@ -1069,6 +1087,11 @@ class VoiceService: base_url=self._higgs_base_url, timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0), ) + if self._melotts_enabled(): + self._melotts_client = httpx.AsyncClient( + base_url=self._melotts_base_url, + timeout=httpx.Timeout(self._melotts_timeout_seconds, connect=3.0), + ) async def shutdown(self) -> None: if self._client is not None: @@ -1077,6 +1100,9 @@ class VoiceService: if self._higgs_client is not None: await self._higgs_client.aclose() self._higgs_client = None + if self._melotts_client is not None: + await self._melotts_client.aclose() + self._melotts_client = None def is_available(self) -> bool: """마이크 캐스케이드(STT+TTS) 전체 가용 여부.""" @@ -1240,6 +1266,8 @@ class VoiceService: ) def tts_available(self, voice: VoicePreset | None = None) -> bool: + if self._melotts_enabled(): + return True if self._higgs_enabled() and (voice is None or self._should_use_higgs_tts(voice)): return True if self._poc_sample_tts_available(): @@ -1247,6 +1275,8 @@ class VoiceService: return bool(self._api_key) def tts_provider(self) -> str: + if self._melotts_enabled(): + return "melotts" if self._higgs_enabled(): return "higgs" if self._poc_sample_tts_available(): @@ -1260,6 +1290,8 @@ class VoiceService: return "unavailable" def tts_provider_for_voice(self, voice: VoicePreset) -> str: + if self._should_use_melotts_tts(voice): + return "melotts" if self._should_use_higgs_tts(voice): return "higgs" if self._should_use_poc_sample_tts(voice): @@ -1267,14 +1299,27 @@ class VoiceService: return "openai" if self._api_key else "unavailable" def tts_model_for_voice(self, voice: VoicePreset) -> str: + if self._should_use_melotts_tts(voice): + return MELOTTS_TTS_MODEL return HIGGS_TTS_MODEL if self._should_use_higgs_tts(voice) else TTS_MODEL def tts_media_type_for_voice(self, voice: VoicePreset) -> str: - return "audio/wav" if self._should_use_higgs_tts(voice) else "audio/mpeg" + if self._should_use_melotts_tts(voice) or self._should_use_higgs_tts(voice): + return "audio/wav" + return "audio/mpeg" def _higgs_enabled(self) -> bool: return self._tts_provider == "higgs" and self._environment == "dev" + def _melotts_enabled(self) -> bool: + # MIT 라이선스라 환경 제한이 없다. Higgs 와 달리 운영에서도 쓸 수 있다. + return self._tts_provider == "melotts" and bool(self._melotts_base_url) + + def _should_use_melotts_tts(self, voice: VoicePreset) -> bool: + # 사전학습 다화자 모델이라 프리셋별 reference 제약이 없다. + del voice + return self._melotts_enabled() + def _should_use_higgs_tts(self, voice: VoicePreset) -> bool: # 현재 권리 안전한 synthetic reference는 P1 서연 프리셋만 보유한다. return self._higgs_enabled() and voice.preset == POC_SAMPLE_TTS_PRESET @@ -1387,6 +1432,10 @@ class VoiceService: text = speakable_text(text) if not text: return + if self._should_use_melotts_tts(voice): + async for chunk in self._synthesize_melotts_tts(text, voice): + yield chunk + return if self._should_use_higgs_tts(voice): async for chunk in self._synthesize_higgs_tts(text, voice): yield chunk @@ -1441,6 +1490,41 @@ class VoiceService: if chunk: yield TTSChunk(audio=chunk) + @property + def _melotts_http(self) -> httpx.AsyncClient: + if not self._melotts_enabled(): + raise VoiceUnavailable("MeloTTS provider is not configured") + if self._melotts_client is None: + self._melotts_client = httpx.AsyncClient( + base_url=self._melotts_base_url, + timeout=httpx.Timeout(self._melotts_timeout_seconds, connect=3.0), + ) + return self._melotts_client + + async def _synthesize_melotts_tts( + self, text: str, voice: VoicePreset + ) -> AsyncIterator[TTSChunk]: + payload = {"text": speakable_text(text), "speed": _clamp_speed(voice.rate)} + try: + async with self._melotts_http.stream( + "POST", MELOTTS_TTS_ENDPOINT, json=payload + ) as response: + response.raise_for_status() + async for chunk in response.aiter_bytes( + chunk_size=POC_SAMPLE_TTS_CHUNK_SIZE + ): + if chunk: + yield TTSChunk(audio=chunk) + except httpx.HTTPStatusError as exc: + body = "" + try: + body = (await exc.response.aread()).decode("utf-8", "ignore")[:200] + except Exception: + pass + raise RuntimeError(f"MeloTTS {exc.response.status_code}: {body}") from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"MeloTTS transport error: {exc}") from exc + async def _synthesize_higgs_tts( self, text: str, voice: VoicePreset ) -> AsyncIterator[TTSChunk]: @@ -1529,6 +1613,7 @@ __all__ = [ "TranscriptWord", "StreamingTranscriptEvent", "DeepgramStreamingSession", + "MELOTTS_TTS_MODEL", "LocalWhisperStreamingSession", "EndOfTurnDecision", "TTSChunk", diff --git a/apps/api/app/test_voice_service.py b/apps/api/app/test_voice_service.py index 5b7d63b..05042b4 100644 --- a/apps/api/app/test_voice_service.py +++ b/apps/api/app/test_voice_service.py @@ -18,6 +18,7 @@ from .services.voice import ( HIGGS_TTS_ENDPOINT, HIGGS_TTS_MODEL, DEEPGRAM_STT_MODEL, + MELOTTS_TTS_MODEL, DeepgramStreamingSession, LocalWhisperStreamingSession, VoicePreset, @@ -864,6 +865,63 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase): self.assertFalse(connected) +class MeloTtsProviderTest(unittest.TestCase): + """MeloTTS 는 MIT 라이선스 사전학습 모델이라 환경·프리셋 제한이 없다.""" + + def _voice(self) -> VoicePreset: + return resolve_voice(persona_code="P1") + + def test_melotts_is_allowed_outside_dev(self) -> None: + service = VoiceService( + api_key="", tts_provider="melotts", environment="prod" + ) + voice = self._voice() + self.assertEqual(service.tts_provider(), "melotts") + self.assertEqual(service.tts_provider_for_voice(voice), "melotts") + self.assertTrue(service.tts_available(voice)) + + def test_higgs_stays_blocked_outside_dev(self) -> None: + service = VoiceService( + api_key="", tts_provider="higgs", environment="prod" + ) + self.assertEqual(service.tts_provider(), "disabled-non-dev") + + def test_melotts_applies_to_every_preset_not_just_p1(self) -> None: + service = VoiceService( + api_key="", tts_provider="melotts", environment="prod" + ) + for code in ("P1", "P2", "P3"): + with self.subTest(persona=code): + voice = resolve_voice(persona_code=code) + self.assertEqual(service.tts_provider_for_voice(voice), "melotts") + + def test_melotts_reports_wav_and_its_own_model(self) -> None: + service = VoiceService( + api_key="", tts_provider="melotts", environment="prod" + ) + voice = self._voice() + self.assertEqual(service.tts_model_for_voice(voice), MELOTTS_TTS_MODEL) + self.assertEqual(service.tts_media_type_for_voice(voice), "audio/wav") + + def test_missing_sidecar_url_disables_melotts(self) -> None: + service = VoiceService( + api_key="openai-key", + tts_provider="melotts", + environment="prod", + melotts_base_url="", + ) + self.assertEqual(service.tts_provider(), "openai") + + def test_openai_selection_is_untouched(self) -> None: + service = VoiceService( + api_key="openai-key", tts_provider="openai", environment="prod" + ) + self.assertEqual(service.tts_provider(), "openai") + self.assertEqual( + service.tts_media_type_for_voice(self._voice()), "audio/mpeg" + ) + + class LocalWhisperProviderSelectionTest(unittest.TestCase): def test_local_whisper_needs_no_api_key(self) -> None: service = VoiceService(api_key="", stt_provider="local_whisper") diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 95abd30..ae9eea7 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -249,7 +249,7 @@ dataset / dataset_item / annotation_round / annotation / export_manifest | DB | **PostgreSQL 16** + pgvector(RAG 통합), NAS 영구볼륨, RLS | Timescale은 hypertable 선택 확장 | | 컨테이너 | **Docker Compose** 단일 호스트 모놀리식(6 컨테이너), Caddy 리버스프록시(`flush_interval -1`) | 이식=compose+secrets, 클라우드 VM(국내 리전) `docker compose up` | | AI 엔진 | **엔진 어댑터** → 기본 Anthropic Messages API(Opus 4.8/Sonnet 4.6), `claude -p`(Max OAuth)는 옵션 플래그 | 과금 누수·동시성·rate limit 흡수. tier: client=Sonnet/Solar, feedback=Opus, cheap=Haiku | -| 음성 | ~~OpenAI gpt-4o-mini-tts + Deepgram STT, 로컬은 폴백~~ → **2026-08-08 결정으로 로컬 우선**: STT는 노트북 상주 faster-whisper(`local_whisper`), TTS는 노트북 Higgs Audio v3. 근거·제약은 [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | s2s 1차 탈락(transcript 보존). Higgs 운영 사용은 라이선스 확인 전까지 dev 전용이라 운영 TTS는 OpenAI 유지 | +| 음성 | ~~OpenAI gpt-4o-mini-tts + Deepgram STT, 로컬은 폴백~~ → **2026-08-08 결정으로 로컬 우선**: STT는 노트북 상주 faster-whisper(`local_whisper`), TTS는 노트북 MeloTTS Korean(`melotts`). 둘 다 MIT. 근거·제약은 [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | s2s 1차 탈락(transcript 보존). Higgs는 연구/비상업 라이선스라 dev 전용으로 남긴다 | | 인증 | **OAuth 2.1 Auth Code + PKCE(S256)** BFF, Google OIDC 1차, Authlib | 토큰 Redis만, HttpOnly `__Host-` 쿠키, stateful 세션(IRB 철회 즉시 무효화) | | 외부노출 | **Cloudflare named tunnel** + SSE는 Tailscale Funnel 분리 | 인바운드 포트 0 | | 안전 | Presidio PII 마스킹 + NeMo rail + 한국어 자살분류 + 결정론 게이트 | Llama Guard 한국어 미지원 보강 | diff --git a/docs/README.md b/docs/README.md index 90907ad..e4c4e6c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -103,7 +103,7 @@ |---|---| | [`decisions/backend-node-transition.md`](./decisions/backend-node-transition.md) | 백엔드 언어 방향(FastAPI 유지·Node 계약 우선 전환) | | [`decisions/voice-s2s-poc.md`](./decisions/voice-s2s-poc.md) | 음성 s2s 2차 PoC 채택 판단 기준 | -| [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | 노트북 로컬 음성 스택 결정 — faster-whisper STT + Higgs TTS, cuDNN·라이선스 제약, G7 게이트 provider 계약 | +| [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md) | 노트북 로컬 음성 스택 결정 — faster-whisper STT + MeloTTS TTS(둘 다 MIT), 설치 함정·cuDNN 제약, G7 게이트 provider 계약 | | [`decisions/outcome-alliance-measurement-ledger.md`](./decisions/outcome-alliance-measurement-ledger.md) | Outcome & Alliance append-only 측정 원장, source/perspective 경계, 전진 복구·롤백 결정 | ## 🧪 Phase 3 파일럿 (forward — 아직 미실행) diff --git a/docs/TODO.md b/docs/TODO.md index ccec08d..bf6ea76 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -62,8 +62,8 @@ 남은 것: NAS용 9100은 실사용 세션 7개가 있어 미접촉이라 (2)는 다음 재기동 때 반영된다. - [ ] **재부팅 후 watchdog smoke** — 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn` 실측. DNS 개통 후 `api-vnet.18ka.net`을 `-AdditionalPublicHealthUrls`로 명시 추가. -- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 STT는 노트북 상주 - faster-whisper, TTS는 노트북 Higgs를 쓴다. 이전에는 코드에 로컬 STT 경로가 아예 없었고 +- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주 + 로컬 모델로 쓴다(STT faster-whisper, TTS는 아래 항목대로 MeloTTS). 이전에는 코드에 로컬 STT 경로가 아예 없었고 (`voice_stt_provider`가 `openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다. `scripts/local-whisper-stt-server.py` 사이드카(Higgs와 같은 loopback 상주 방식)와 `voice_stt_provider=local_whisper` 경로를 추가했다. 오디오는 호스트를 벗어나지 않고 외부 STT 키가 @@ -77,11 +77,18 @@ **발견 2:** G7 종료 체커가 `expected_stt_provider == "deepgram"`을 하드코딩하고 있었다. 이는 결정 기록이 아니라 벤더 한 줄이었으므로, 운영하기로 한 provider 허용목록으로 바꿨다 (`ALLOWED_STT_PROVIDERS`/`ALLOWED_TTS_PROVIDERS`). 선언/실제 불일치 차단은 그대로 유지한다. -- [ ] **Higgs 운영 사용 라이선스 결정** — 소유자는 TTS를 노트북 Higgs로 쓰기로 결정했으나, - `apps/api/app/config.py`가 `environment != dev`에서 `VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다 - (Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는 법적 판단이라 임의로 제거하지 않았다. - 산학협력 연구·교육 용도가 해당 라이선스 범위에 들어가는지 확인하고 `decisions/`에 근거를 남긴 뒤 - 가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는 OpenAI `gpt-4o-mini-tts`로 유지한다. +- [x] **노트북 로컬 음성 스택 TTS 구현 (MeloTTS)** — Higgs는 연구/비상업 라이선스라 `config.py`가 + 운영에서 차단하고 있었고, 그 가드를 푸는 건 법적 판단이라 코드로 결정할 수 없었다. 그래서 상업 사용이 + 허용된 설치형을 다시 찾아 **MeloTTS Korean(MIT)** 으로 바꿨다. 가드를 건드릴 필요가 사라졌다. + `scripts/melotts-server.py` loopback 사이드카 + `voice_tts_provider=melotts` 경로를 추가했고, + 사전학습 다화자 모델이라 Higgs처럼 P1 프리셋 한정이 아니라 모든 페르소나에 적용된다. + **실측:** CPU 정상 상태 RTF 0.27~0.28(실시간의 3.6배), `POST /tts` 200·WAV 350,566 bytes·3.61s, + 빈 텍스트 422·미지 경로 404. **왕복 검증**으로 MeloTTS 합성음을 faster-whisper가 완전 일치 전사했다. + 회귀 사이드카 16/16, API 전체 914 passed, ruff clean. + **검토 후 탈락:** Kokoro-82M은 Apache 2.0이지만 공식 `VOICES.md`에 **한국어가 없다**(검색 요약이 + 지원한다고 했으나 오답). Piper는 GPL, XTTS-v2·Fish Speech는 비상업이라 Higgs와 같은 문제다. + 설치 함정 3가지는 `decisions/local-voice-stack.md`에 남겼다(setuptools<81, unidic download, eunjeon). + 근거: [`decisions/local-voice-stack.md`](./decisions/local-voice-stack.md). - [ ] **음성 캐스케이드 live** — Deepgram streaming adapter와 interim/final·word timestamp, bounded event queue, 운영 10MiB 사전 거부와 실제 PostgreSQL 동시 철회 직렬화는 코드/내부 회귀까지 완료했다. 종료에는 운영 key·quota를 사용한 live interim/final, 기대 provider/model과 일치하는 authenticated public WSS ready, diff --git a/docs/decisions/local-voice-stack.md b/docs/decisions/local-voice-stack.md index 31ecdd5..74f1c91 100644 --- a/docs/decisions/local-voice-stack.md +++ b/docs/decisions/local-voice-stack.md @@ -1,15 +1,38 @@ -# 로컬 음성 스택 결정 — 노트북 faster-whisper STT + Higgs TTS +# 로컬 음성 스택 결정 — 노트북 faster-whisper STT + MeloTTS TTS -Date: 2026-08-08 -Status: accepted (STT 구현 완료 · TTS 운영 사용은 라이선스 확인 대기) +Date: 2026-08-08 (같은 날 TTS 재선정으로 개정) +Status: accepted — STT·TTS 모두 구현·실측 완료, 라이선스 제약 없음 Owner decision: 윤찬 ## 결정 음성 캐스케이드의 양쪽을 **개발 노트북에 상주하는 로컬 모델**로 운영한다. +둘 다 **허용적 라이선스(MIT)** 라 운영 사용에 제약이 없다. -- **STT(듣기)**: faster-whisper (`local_whisper` provider) -- **TTS(말하기)**: Higgs Audio v3 TTS 4B +- **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 에는 없으므로 +모든 페르소나 프리셋에 적용된다. ## 이 결정이 뒤집는 것 @@ -45,6 +68,9 @@ Owner decision: 윤찬 (`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`. ## 실측 증거 (2026-08-08) @@ -61,6 +87,39 @@ Owner decision: 윤찬 `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`이 없어 @@ -69,11 +128,9 @@ API 전체 908 passed, gateway 58, G7 checker 23, ruff clean이다. (`--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`를 차단한다(Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는 - 법적 판단이라 코드에서 임의로 제거하지 않았다. 산학협력 연구·교육 용도가 라이선스 범위에 들어가는지 - 확인하고 그 근거를 이 문서에 추가한 뒤 가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는 - OpenAI `gpt-4o-mini-tts`를 유지한다. +2. **Higgs 가드는 그대로 둔다.** `apps/api/app/config.py`는 계속 `environment != dev`에서 + `VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다. MeloTTS 채택으로 이 가드를 풀 이유가 없어졌다. + Higgs 를 굳이 운영에서 쓰고 싶어지면 그때 라이선스 근거를 이 문서에 추가하고 가드를 조정한다. 3. **CPU 폴백 성능.** CPU int8에서는 `small`이 현실적이다. `large-v3`는 CPU에서 실시간 스트리밍에 맞추기 어렵다. @@ -83,7 +140,7 @@ API 전체 908 passed, gateway 58, G7 checker 23, ruff clean이다. ```python ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram") -ALLOWED_TTS_PROVIDERS = ("higgs", "openai") +ALLOWED_TTS_PROVIDERS = ("melotts", "higgs", "openai") ``` 게이트를 약화시키지 않았다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어 목록에 없고, diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 885b1d7..670424a 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -1122,7 +1122,7 @@
python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / DB mode with local DATABASE_URLdata/kb source pack, env template keys, and DB readiness (current_user=vignette). DB mode can additionally check app-role DSN with --require-app-role and now verifies session read-model columns including app.turns voice metadata columns(audio_ref/silence_ms/speech_rate/barge_in/provider_events) plus worksheet review columns.docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build + proxy /api/healthhttp://localhost:18080/api/health 200 with db:true, engine:true, engine_mode:"claude_cli". Smoke volumes/network removed after run.http://100.116.83.60:8088 / vignette-e2e 매일 04:30 KST / 배포 증거 / 기계 판독 증거6030a677…c611을 전용 Compose 프로젝트·포트·네트워크·named volume에 승격했다. browser E2E 108/108, health 3/3, OpenAPI 126·auth 401·G0~G8, postdeploy SSE→DB review를 통과했고 predeploy dump와 이전 exact images를 보존했다. 자동화는 ACTIVE이며 material milestone+release gate+배포 SHA 변경 때만 프리뷰를 갱신한다. G7 외부 mic/provider/human 증거는 별도다.scripts/local-whisper-stt-server.py · scripts/start-local-whisper-stt.ps1 · voice_stt_provider=local_whisper9·final 5·word timestamp 12개 present, 전사는 원문과 한 글자 차이. 회귀 사이드카 37/37, API 전체 908 passed, gateway 58, G7 checker 23, ruff clean. 이 노트북은 CUDA는 보이지만 cuDNN 부재로 GPU 추론이 네이티브 크래시라 디바이스 확인을 자식 프로세스(--self-check)로 분리하고 CPU 폴백을 넣었다. cuDNN 9 설치는 소유자 판단으로 남겼다. G7 체커의 deepgram 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다.scripts/local-whisper-stt-server.py · scripts/start-local-whisper-stt.ps1 · voice_stt_provider=local_whisperconfig.py가 운영에서 차단하고 있었고 그 가드를 푸는 건 법적 판단이라, 상업 사용이 허용된 MeloTTS Korean(MIT)으로 바꿔 가드 자체를 불필요하게 만들었다. Kokoro-82M은 Apache 2.0이지만 공식 VOICES.md에 한국어가 없어 탈락했다. 이전에는 로컬 STT 경로가 코드에 없었고 interim/final 스트리밍이 Deepgram 전용이었다. Higgs와 같은 loopback 사이드카를 추가해 오디오가 호스트를 벗어나지 않고 외부 STT 키도 필요 없다. 실측: 저장소 합성 시드 8.72초에서 interim 9·final 5·word timestamp 12개 present, 전사는 원문과 한 글자 차이. 회귀 사이드카 37/37, API 전체 908 passed, gateway 58, G7 checker 23, ruff clean. 이 노트북은 CUDA는 보이지만 cuDNN 부재로 GPU 추론이 네이티브 크래시라 디바이스 확인을 자식 프로세스(--self-check)로 분리하고 CPU 폴백을 넣었다. cuDNN 9 설치는 소유자 판단으로 남겼다. MeloTTS는 CPU 정상 상태 RTF 0.27~0.28(실시간 3.6배), POST /tts 200·WAV 350,566 bytes·3.61s, 빈 텍스트 422·미지 경로 404이며, 합성음을 로컬 STT가 완전 일치 전사하는 왕복 검증을 통과했다(사이드카 16/16, API 914 passed). G7 체커의 deepgram 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다.apps/web/src/lib/uuid.ts · apps/web/e2e/insecure-context-uuid.spec.tscrypto.randomUUID is not a function으로 error boundary에 잡혔다. 이 API는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 RuptureRepairCard는 렌더 시점 호출이라 화면 전체가 죽었다. 릴리스 게이트는 localhost 후보 스택에서만 돌아 미검출. randomUuid()로 통일하고 fallback도 getRandomValues 우선으로 예측 불가능성을 유지했다. 회귀 6/6(직접 호출 0건 검사 포함), typecheck·build 통과. NAS 프리뷰에는 아직 미배포이며 배포된 SHA 6030a677…c611은 여전히 결함 빌드다.scripts/watch-public-runtime.ps1 · scripts/start-public-runtime.ps1 · apps/api/engine_gateway/gateway.py/ready?force=true 200 ok:true·detail:OK, API 8001과 공개 api-vignette.chanpaca.net 모두 status:ok·engine:true·engine_detail:OK다. 원인은 16:06부터 상주한 9099 프로세스 하나였다(CLI·코드·cwd는 정상 — 동일 인자 직접 실행과 EngineSession 재현이 "OK"를 반환). 재발 방지 3건: (1) 워치독·기동 스크립트의 engine 판정을 /health(프로세스 liveness)에서 /ready(실제 생성)로 바꾸고 API 판정에 engine을 추가, shared secret 인스턴스용 토큰 헤더와 503 본문 로깅 포함. (2) 게이트웨이가 자식 claude -p의 stderr를 상시 드레인해(PIPE 미독 시 자식 블록도 차단) 실패 detail에 exit·stderr를 붙인다 — 실증: empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz'). (3) 기동 시 게이트웨이 로그 회전, Stop-UvicornByPort에 Name -like python* 추가(호출자 자기 자신 종료 방지). 검증: pytest engine_gateway 58/58, 워치독 -CheckOnly healthy 5/5, 장애 재현(9299·CLAUDE_BIN 부재)에서 /health는 ok:true로 통과하고 /ready는 503으로 검출되어 워치독이 unhealthy (1/3) + 503 본문을 남겼다. NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다.scripts/launch-nas-preview-g8-helpers.py / scripts/serve-nas-preview-rollback-executor.py / scripts/probe-nas-preview-g8-rollback.py / 런북 / 기계 판독 증거nas-g8-723eeef2…로 previous 79ec…4450/c530…2f28, receipt nas-g8-2738846c…로 current 52e0…8b2d/6fdb…f215를 활성화했다. release gate·approval 각각 2회 멱등, audit.ci_lifecycle_event rollback/executed 2건과 receipt id 결속, silent auto-promotion 0, HMAC journal 6-record 체인 검증, health 3/3·OpenAPI 126·auth 401·Web 200, helper 0·listener 0 복귀. helper 격리 계약은 image 상속 label baseline 대비 검증이며 fake-runner 37/37로 고정했다. down/volume rm/prune 미실행, 공개 런타임 미접촉.