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 @@ Deploy preflightpython scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets / DB mode with local DATABASE_URLPassed: exact-pinned API requirements, live coaching data/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. Fresh compose smokedocker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build + proxy /api/healthPassed with dummy production-safe env: API healthy, DB healthy, web/proxy up, http://localhost:18080/api/health 200 with db:true, engine:true, engine_mode:"claude_cli". Smoke volumes/network removed after run. 격리 NAS 프리뷰 · 회기 E2E 자동화http://100.116.83.60:8088 / vignette-e2e 매일 04:30 KST / 배포 증거 / 기계 판독 증거current SHA 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 증거는 별도다. - 노트북 로컬 음성 스택 — faster-whisper STTscripts/local-whisper-stt-server.py · scripts/start-local-whisper-stt.ps1 · voice_stt_provider=local_whisper2026-08-08 소유자 결정으로 STT를 노트북 상주 faster-whisper로, TTS를 노트북 Higgs로 정했다. 이전에는 로컬 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 설치는 소유자 판단으로 남겼다. G7 체커의 deepgram 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다. + 노트북 로컬 음성 스택 — faster-whisper STT + MeloTTS TTSscripts/local-whisper-stt-server.py · scripts/start-local-whisper-stt.ps1 · voice_stt_provider=local_whisper2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주 로컬 모델로 정했다. TTS는 처음 Higgs로 잡았으나 연구/비상업 라이선스라 config.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 전용이다. 비-secure origin 회기 리뷰 크래시 수정apps/web/src/lib/uuid.ts · apps/web/e2e/insecure-context-uuid.spec.ts격리 NAS 프리뷰(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가 crypto.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은 여전히 결함 빌드다. 공개 워치독 engine readiness 사각지대(해결)scripts/watch-public-runtime.ps1 · scripts/start-public-runtime.ps1 · apps/api/engine_gateway/gateway.py2026-08-07 22:0x KST 소유자 승인으로 9099를 재기동해 복구했다. 재기동 직후 /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-UvicornByPortName -like python* 추가(호출자 자기 자신 종료 방지). 검증: pytest engine_gateway 58/58, 워치독 -CheckOnly healthy 5/5, 장애 재현(9299·CLAUDE_BIN 부재)에서 /healthok:true로 통과하고 /ready는 503으로 검출되어 워치독이 unhealthy (1/3) + 503 본문을 남겼다. NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다. G8 실제 receipt-bound image rollbackscripts/launch-nas-preview-g8-helpers.py / scripts/serve-nas-preview-rollback-executor.py / scripts/probe-nas-preview-g8-rollback.py / 런북 / 기계 판독 증거격리 NAS에서 별도 executor(18149)·control-plane(8018) helper로 실제 image rollback 2회를 실행했다. receipt 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 미실행, 공개 런타임 미접촉. diff --git a/docs/guides/local-development.md b/docs/guides/local-development.md index 59c7584..7affe70 100644 --- a/docs/guides/local-development.md +++ b/docs/guides/local-development.md @@ -29,6 +29,10 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 # 로컬 Higgs Audio v3 P1 음성까지 함께 연결 powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 -UseHiggsVoice +# 노트북 상주 MeloTTS 한국어 TTS 사이드카 (MIT, 운영에서도 사용 가능) +powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-melotts.ps1 +# 그 뒤 API 에 VIGNETTE_VOICE_TTS_PROVIDER=melotts 를 준다. + # 노트북 상주 faster-whisper STT 사이드카 (외부 STT 키 불필요) powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-local-whisper-stt.ps1 -Model small # 그 뒤 API 에 VIGNETTE_VOICE_STT_PROVIDER=local_whisper 를 준다. @@ -166,6 +170,7 @@ npm install | `AUTH_NEW_USER_DEFAULT_STATUS` | `pending` | Google/SAML 신규 사용자의 기본 승인 상태. `dev:` 로그인은 로컬/E2E 편의를 위해 자동 승인 | | `AUTH_EMAIL_COHORT_MAP` | `{}` | 특정 이메일을 cohort id로 매핑한다. 값은 comma-separated 문자열도 허용 | | `AUTH_DOMAIN_COHORT_MAP` | `{}` | 이메일/Google hosted domain을 cohort id로 매핑한다. Google/SAML/dev-login 세션 `cohort_ids`에 반영 | +| `VIGNETTE_MELOTTS_TTS_URL` | `http://127.0.0.1:9883` | 로컬 MeloTTS 한국어 사이드카. `scripts/start-melotts.ps1`로 띄운다 | | `VIGNETTE_VOICE_STT_PROVIDER` | `openai` 또는 `deepgram` | STT 공급자. `deepgram`은 streaming interim/final 경로, `openai`는 batch 경로 | | `DEEPGRAM_API_KEY` | 비밀값 | Deepgram streaming credential. key 없이 `deepgram`을 고르면 `OPENAI_API_KEY`가 있을 때만 `openai-batch-fallback`; live Deepgram 증거가 아님 | | `DEEPGRAM_STT_URL` / `DEEPGRAM_STT_MODEL` / `DEEPGRAM_STT_LANGUAGE` | `wss://api.deepgram.com/v1/listen` / `nova-3` / `ko` | Deepgram endpoint와 provider/model metadata 계약 | @@ -173,7 +178,7 @@ npm install | `DEEPGRAM_KEEPALIVE_SECONDS` / `DEEPGRAM_FINALIZE_TIMEOUT_SECONDS` | `4` / `15` | streaming keepalive와 final 대기 상한 | | `DEEPGRAM_MIP_OPT_OUT` | `true` | Deepgram model improvement program opt-out query 기본값 | | `VIGNETTE_VOICE_POC_SAMPLE_TTS` | `false` | P1 무참조 샘플 음성을 `/voice/ws` TTS에 연결하는 개발 전용 플래그. 마이크/STT는 선택한 STT provider credential 필요, 프로덕션 금지 | -| `VIGNETTE_VOICE_TTS_PROVIDER` | `openai` 또는 `higgs` | TTS 공급자. `higgs`는 dev + P1에서만 허용하며 다른 환경은 설정 검증에서 차단. 동작 자체는 운영 상업 이용권 증거를 대신하지 않음 | +| `VIGNETTE_VOICE_TTS_PROVIDER` | `openai`·`higgs`·`melotts` | TTS 공급자. `higgs`는 dev + P1에서만 허용하며 다른 환경은 설정 검증에서 차단. 동작 자체는 운영 상업 이용권 증거를 대신하지 않음 | | `VIGNETTE_HIGGS_TTS_URL` | `http://127.0.0.1:9881` | 로컬 Higgs 상주 서버. 저장소의 무참조 synthetic seed만 화자 참조로 사용 | | `VIGNETTE_VOICE_STT_PROVIDER` | `openai`·`deepgram`·`local_whisper` | STT 공급자. `local_whisper`는 노트북 상주 faster-whisper 사이드카를 쓴다(외부 키 불필요, 오디오가 호스트를 벗어나지 않음). `openai`는 배치라 interim 이 없다 | | `VIGNETTE_LOCAL_WHISPER_STT_URL` | `ws://127.0.0.1:9882/v1/listen` | 로컬 whisper 사이드카. `scripts/start-local-whisper-stt.ps1`로 띄운다 | diff --git a/scripts/check-g7-external-proof.py b/scripts/check-g7-external-proof.py index c6d6b9d..76e77f9 100644 --- a/scripts/check-g7-external-proof.py +++ b/scripts/check-g7-external-proof.py @@ -32,14 +32,15 @@ MINIMUM_SOAK_SECONDS = 3_000.0 # 이 게이트는 특정 벤더가 아니라 **운영하기로 결정한 provider** 를 강제한다. # 2026-08-08 소유자 결정: STT 는 노트북 상주 faster-whisper(`local_whisper`), -# TTS 는 노트북 Higgs(`higgs`). 이전 값은 벤더 하나(`deepgram`/`openai`)가 -# 하드코딩돼 있었을 뿐 결정 기록이 아니었다. +# TTS 는 노트북 MeloTTS(`melotts`). 둘 다 MIT 라 운영 사용 제약이 없다. +# 이전 값은 벤더 하나(`deepgram`/`openai`)가 하드코딩돼 있었을 뿐 결정 기록이 아니었다. +# 근거는 docs/decisions/local-voice-stack.md. # # 목록은 닫혀 있다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어 # 여기 들어오지 못한다. 어떤 경우에도 `expected_* == ready_*` 결속은 유지되므로 # 선언한 provider 와 실제로 돈 provider 가 다르면 계속 실패한다. ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram") -ALLOWED_TTS_PROVIDERS = ("higgs", "openai") +ALLOWED_TTS_PROVIDERS = ("melotts", "higgs", "openai") def load_json(path: Path, errors: list[str], label: str) -> dict[str, Any]: diff --git a/scripts/melotts-server.py b/scripts/melotts-server.py new file mode 100644 index 0000000..59a8238 --- /dev/null +++ b/scripts/melotts-server.py @@ -0,0 +1,253 @@ +# -*- coding: utf-8 -*- +"""로컬 상주 MeloTTS 한국어 TTS 사이드카. + +Higgs 서버와 같은 loopback 상주 방식이지만 권리 조건이 다르다. MeloTTS 는 MIT +라이선스라 상업·비상업 사용에 제약이 없고, 사전학습된 한국어 다화자 모델이라 +실존 인물 음성 reference 를 전혀 쓰지 않는다. 그래서 Higgs 처럼 dev 전용 가드나 +P1 프리셋 한정이 필요 없다. + +엔드포인트: + GET /health → {"status":"ok","model":...,"language":"KR","license":"MIT",...} + POST /tts → {"text": "...", "speed": 1.0} 를 받아 WAV 바이트를 돌려준다 + +텍스트는 메모리에서만 다루고 디스크에 쓰지 않는다. +""" + +from __future__ import annotations + +import argparse +import array +import io +import json +import math +import sys +import wave +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any, Iterable, Protocol + + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 9883 +DEFAULT_LANGUAGE = "KR" +DEFAULT_SPEED = 1.0 +MIN_SPEED = 0.5 +MAX_SPEED = 2.0 +MAX_TEXT_CHARS = 2_000 +MAX_BODY_BYTES = 64 * 1024 +MODEL_ID = "melotts-korean" +LICENSE_ID = "MIT" + + +class TtsError(RuntimeError): + """합성 실패. 입력 텍스트는 로그·응답에 다시 싣지 않는다.""" + + def __init__(self, code: str, *, http_status: int = 422): + self.code = code + self.http_status = http_status + super().__init__(code) + + +@dataclass(frozen=True, slots=True) +class TtsRequest: + text: str + speed: float + + +class Synthesizer(Protocol): + sample_rate: int + + def synthesize(self, text: str, *, speed: float) -> Iterable[float]: ... + + +def clamp_speed(value: Any) -> float: + """말 속도를 안전 범위로 접는다. 숫자가 아니면 기본값.""" + + try: + speed = float(value) + except (TypeError, ValueError): + return DEFAULT_SPEED + if math.isnan(speed) or math.isinf(speed): + return DEFAULT_SPEED + return max(MIN_SPEED, min(MAX_SPEED, speed)) + + +def parse_tts_request(payload: Any) -> TtsRequest: + """요청 본문을 검증한다. 빈 텍스트와 과대 입력은 fail-closed.""" + + if not isinstance(payload, dict): + raise TtsError("invalid_body") + text = payload.get("text") + if not isinstance(text, str): + raise TtsError("text_required") + text = text.strip() + if not text: + raise TtsError("text_required") + if len(text) > MAX_TEXT_CHARS: + raise TtsError("text_too_long", http_status=413) + return TtsRequest(text=text, speed=clamp_speed(payload.get("speed", DEFAULT_SPEED))) + + +def encode_wav(samples: Iterable[float], sample_rate: int) -> bytes: + """float(-1..1) 시퀀스를 16-bit mono WAV 로 만든다.""" + + if sample_rate <= 0: + raise TtsError("invalid_sample_rate", http_status=500) + pcm = array.array("h") + for sample in samples: + value = float(sample) + if math.isnan(value): + value = 0.0 + clipped = max(-1.0, min(1.0, value)) + pcm.append(int(round(clipped * 32767))) + if sys.byteorder != "little": # pragma: no cover - little-endian 개발 환경 + pcm.byteswap() + buffer = io.BytesIO() + with wave.open(buffer, "wb") as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(sample_rate) + handle.writeframes(pcm.tobytes()) + return buffer.getvalue() + + +def health_payload(synthesizer: Synthesizer, *, speakers: Iterable[str]) -> dict[str, Any]: + return { + "status": "ok", + "model": MODEL_ID, + "language": DEFAULT_LANGUAGE, + "license": LICENSE_ID, + "reference_policy": "pretrained-multispeaker-no-external-reference", + "sample_rate": synthesizer.sample_rate, + "speakers": sorted(speakers), + } + + +class MeloSynthesizer: + """상주 MeloTTS 모델 하나. 텍스트는 메모리에서만 다룬다.""" + + def __init__(self, *, device: str, language: str = DEFAULT_LANGUAGE) -> None: + try: + from melo.api import TTS + except ImportError as exc: # pragma: no cover - 런타임 환경 의존 + raise TtsError("melotts_unavailable", http_status=500) from exc + self._model = TTS(language=language, device=device) + self.device = device + self.language = language + self.speaker_ids = dict(self._model.hps.data.spk2id) + if not self.speaker_ids: # pragma: no cover - 모델 무결성 + raise TtsError("melotts_no_speaker", http_status=500) + self._speaker_id = next(iter(self.speaker_ids.values())) + self.sample_rate = int(self._model.hps.data.sampling_rate) + + def synthesize(self, text: str, *, speed: float) -> Iterable[float]: + audio = self._model.tts_to_file( + text, self._speaker_id, None, speed=speed, quiet=True + ) + return audio + + +def make_handler( + synthesizer: Synthesizer, *, speakers: Iterable[str] +) -> type[BaseHTTPRequestHandler]: + speaker_list = list(speakers) + + class Handler(BaseHTTPRequestHandler): + server_version = "VignetteMeloTTS/1" + sys_version = "" + + def log_message(self, format: str, *args: object) -> None: + return None + + def _json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: # noqa: N802 + if self.path != "/health": + self._json(404, {"detail": "not_found"}) + return + self._json(200, health_payload(synthesizer, speakers=speaker_list)) + + def do_POST(self) -> None: # noqa: N802 + if self.path != "/tts": + self._json(404, {"detail": "not_found"}) + return + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + self._json(400, {"detail": "invalid_content_length"}) + return + if length < 1 or length > MAX_BODY_BYTES: + self._json(413, {"detail": "request_size_rejected"}) + return + try: + payload = json.loads(self.rfile.read(length)) + except (UnicodeDecodeError, ValueError): + self._json(400, {"detail": "invalid_json"}) + return + try: + request = parse_tts_request(payload) + audio = synthesizer.synthesize(request.text, speed=request.speed) + wav = encode_wav(audio, synthesizer.sample_rate) + except TtsError as exc: + self._json(exc.http_status, {"detail": exc.code}) + return + except Exception: + self._json(500, {"detail": "synthesis_failed"}) + return + self.send_response(200) + self.send_header("Content-Type", "audio/wav") + self.send_header("Content-Length", str(len(wav))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Vignette-TTS-Provider", "melotts") + self.send_header("X-Vignette-TTS-Model", MODEL_ID) + self.send_header("X-Vignette-TTS-License", LICENSE_ID) + self.end_headers() + self.wfile.write(wav) + + return Handler + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default=DEFAULT_HOST, choices=[DEFAULT_HOST, "localhost"]) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--device", default="auto") + parser.add_argument("--language", default=DEFAULT_LANGUAGE) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: # pragma: no cover - CLI + args = build_parser().parse_args(list(argv) if argv is not None else None) + synthesizer = MeloSynthesizer(device=args.device, language=args.language) + print( + json.dumps( + { + "ready": True, + "host": args.host, + "port": args.port, + "model": MODEL_ID, + "license": LICENSE_ID, + "language": synthesizer.language, + "device": synthesizer.device, + "sample_rate": synthesizer.sample_rate, + "speakers": sorted(synthesizer.speaker_ids), + }, + ensure_ascii=False, + separators=(",", ":"), + ), + flush=True, + ) + handler = make_handler(synthesizer, speakers=synthesizer.speaker_ids) + HTTPServer((args.host, args.port), handler).serve_forever() + return 0 + + +if __name__ == "__main__": # pragma: no cover - CLI + raise SystemExit(main()) diff --git a/scripts/start-melotts.ps1 b/scripts/start-melotts.ps1 new file mode 100644 index 0000000..bd6005c --- /dev/null +++ b/scripts/start-melotts.ps1 @@ -0,0 +1,93 @@ +param( + [int]$Port = 9883, + [string]$Language = 'KR', + [ValidateSet('auto', 'cuda', 'cpu')] + [string]$Device = 'auto', + [int]$WaitReadySeconds = 300 +) + +# 노트북 상주 MeloTTS 한국어 TTS 사이드카를 띄운다. +# MIT 라이선스 사전학습 다화자 모델이라 상업 사용 제약도, 실존 인물 reference 문제도 없다. +# 그래서 Higgs 와 달리 dev 전용 가드나 P1 프리셋 한정이 필요 없다. + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$OutputEncoding = [System.Text.UTF8Encoding]::new($false) + +$repoRoot = Split-Path -Parent $PSScriptRoot +$serverScript = Join-Path $PSScriptRoot 'melotts-server.py' +$venvPython = 'C:\Users\encep\.venvs\vignette-melotts\Scripts\python.exe' +$healthUrl = "http://127.0.0.1:$Port/health" +$runtimeLogDir = Join-Path $env:TEMP 'Vignette\melotts' +$stdoutLog = Join-Path $runtimeLogDir 'server.out.log' +$stderrLog = Join-Path $runtimeLogDir 'server.err.log' + +if (!(Test-Path -LiteralPath $serverScript)) { + throw "MeloTTS 서버 스크립트를 찾지 못했습니다: $serverScript" +} +if (!(Test-Path -LiteralPath $venvPython)) { + throw @" +MeloTTS 전용 venv 를 찾지 못했습니다: $venvPython +설치 순서: + python -m venv C:\Users\encep\.venvs\vignette-melotts + & C:\Users\encep\.venvs\vignette-melotts\Scripts\python.exe -m pip install "git+https://github.com/myshell-ai/MeloTTS.git" + & ... -m pip install "setuptools<81" eunjeon # librosa 0.9.1 의 pkg_resources, 한국어 g2p + & ... -m unidic download # MeloTTS 가 무조건 임포트하는 일본어 사전 +"@ +} + +try { + $currentHealth = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3 + if ($currentHealth.status -eq 'ok') { + Write-Output "MeloTTS가 이미 준비됐습니다: $healthUrl" + $currentHealth | ConvertTo-Json -Depth 4 -Compress + return + } +} catch { + # 아직 서버가 없으면 아래에서 시작한다. +} + +$listener = Get-NetTCPConnection -State Listen -LocalPort $Port -ErrorAction SilentlyContinue +if ($null -ne $listener) { + throw "포트 $Port 를 다른 프로세스가 사용 중입니다. 임의 종료하지 않았습니다." +} + +New-Item -ItemType Directory -Path $runtimeLogDir -Force | Out-Null +$serverArgs = @( + '-X', 'utf8', $serverScript, + '--host', '127.0.0.1', + '--port', "$Port", + '--language', $Language, + '--device', $Device +) +$process = Start-Process -WindowStyle Hidden -FilePath $venvPython ` + -ArgumentList $serverArgs ` + -WorkingDirectory $repoRoot ` + -RedirectStandardOutput $stdoutLog ` + -RedirectStandardError $stderrLog ` + -PassThru + +Write-Output "MeloTTS 로드를 시작했습니다. PID=$($process.Id) language=$Language device=$Device" +Write-Output "로그: $stdoutLog" +if ($WaitReadySeconds -le 0) { + return +} + +$deadline = (Get-Date).AddSeconds($WaitReadySeconds) +while ((Get-Date) -lt $deadline) { + if ($process.HasExited) { + throw "MeloTTS가 준비되기 전에 종료됐습니다. stderr=$stderrLog" + } + try { + $health = Invoke-RestMethod -Uri $healthUrl -Method Get -TimeoutSec 3 + if ($health.status -eq 'ok') { + Write-Output "MeloTTS 준비 완료: $healthUrl" + $health | ConvertTo-Json -Depth 4 -Compress + return + } + } catch { + Start-Sleep -Seconds 3 + } +} + +throw "MeloTTS 준비 시간이 ${WaitReadySeconds}초를 넘었습니다. 로그: $stdoutLog" diff --git a/scripts/test_g7_external_proof.py b/scripts/test_g7_external_proof.py index 8f24e06..93d4902 100644 --- a/scripts/test_g7_external_proof.py +++ b/scripts/test_g7_external_proof.py @@ -155,10 +155,10 @@ def public_soak() -> dict[str, object]: "ready_stt_provider": "local_whisper", "expected_stt_model": "large-v3", "ready_stt_model": "large-v3", - "expected_tts_provider": "higgs", - "ready_tts_provider": "higgs", - "expected_tts_model": "higgs-audio-v3-tts-4b", - "ready_tts_model": "higgs-audio-v3-tts-4b", + "expected_tts_provider": "melotts", + "ready_tts_provider": "melotts", + "expected_tts_model": "melotts-korean", + "ready_tts_model": "melotts-korean", "turns_attempted": 10, "turns_succeeded": 10, "turn_transcript_metrics": metrics, @@ -287,12 +287,12 @@ class G7ExternalProofTests(unittest.TestCase): self.assertGreaterEqual(gain["held_out_participants"], 30) def test_decided_local_stack_is_accepted(self) -> None: - """2026-08-08 소유자 결정: 노트북 faster-whisper STT + Higgs TTS.""" + """2026-08-08 소유자 결정: 노트북 faster-whisper STT + MeloTTS TTS (둘 다 MIT).""" errors: list[str] = [] voice = public_soak() self.assertEqual(voice["expected_stt_provider"], "local_whisper") - self.assertEqual(voice["expected_tts_provider"], "higgs") + self.assertEqual(voice["expected_tts_provider"], "melotts") self.checker.validate_public_soak(voice, errors) self.assertEqual([], errors) diff --git a/scripts/test_melotts_server.py b/scripts/test_melotts_server.py new file mode 100644 index 0000000..5ac739c --- /dev/null +++ b/scripts/test_melotts_server.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import importlib.util +import io +import math +import sys +import unittest +import wave +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).with_name("melotts-server.py") +SPEC = importlib.util.spec_from_file_location("melotts_server", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class FakeSynthesizer: + sample_rate = 44_100 + + def __init__(self) -> None: + self.calls: list[tuple[str, float]] = [] + + def synthesize(self, text: str, *, speed: float): + self.calls.append((text, speed)) + return [0.0, 0.5, -0.5, 1.0, -1.0] + + +class ClampSpeedTest(unittest.TestCase): + def test_in_range_speed_is_kept(self) -> None: + self.assertEqual(MODULE.clamp_speed(1.25), 1.25) + + def test_out_of_range_speed_is_folded(self) -> None: + self.assertEqual(MODULE.clamp_speed(99), MODULE.MAX_SPEED) + self.assertEqual(MODULE.clamp_speed(-99), MODULE.MIN_SPEED) + + def test_non_numeric_and_nan_fall_back_to_default(self) -> None: + for value in ("fast", None, [], float("nan"), float("inf")): + with self.subTest(value=value): + self.assertEqual(MODULE.clamp_speed(value), MODULE.DEFAULT_SPEED) + + +class ParseRequestTest(unittest.TestCase): + def test_valid_request_is_normalized(self) -> None: + request = MODULE.parse_tts_request({"text": " 안녕하세요 ", "speed": 1.1}) + self.assertEqual(request.text, "안녕하세요") + self.assertAlmostEqual(request.speed, 1.1) + + def test_speed_defaults_when_absent(self) -> None: + request = MODULE.parse_tts_request({"text": "안녕"}) + self.assertEqual(request.speed, MODULE.DEFAULT_SPEED) + + def test_empty_or_missing_text_fails_closed(self) -> None: + for payload in ({}, {"text": ""}, {"text": " "}, {"text": 5}, []): + with self.subTest(payload=payload): + with self.assertRaises(MODULE.TtsError): + MODULE.parse_tts_request(payload) + + def test_oversized_text_is_rejected_with_413(self) -> None: + with self.assertRaises(MODULE.TtsError) as ctx: + MODULE.parse_tts_request({"text": "가" * (MODULE.MAX_TEXT_CHARS + 1)}) + self.assertEqual(ctx.exception.http_status, 413) + self.assertEqual(ctx.exception.code, "text_too_long") + + +class EncodeWavTest(unittest.TestCase): + def _read(self, payload: bytes): + with wave.open(io.BytesIO(payload), "rb") as handle: + return handle.getnchannels(), handle.getsampwidth(), handle.getframerate(), handle.readframes(handle.getnframes()) + + def test_wav_header_is_mono_16bit(self) -> None: + payload = MODULE.encode_wav([0.0, 0.25], 22_050) + channels, width, rate, frames = self._read(payload) + self.assertEqual((channels, width, rate), (1, 2, 22_050)) + self.assertEqual(len(frames), 4) + + def test_samples_are_clipped_into_int16_range(self) -> None: + payload = MODULE.encode_wav([2.0, -2.0], 16_000) + _, _, _, frames = self._read(payload) + self.assertEqual( + int.from_bytes(frames[0:2], "little", signed=True), 32_767 + ) + self.assertEqual( + int.from_bytes(frames[2:4], "little", signed=True), -32_767 + ) + + def test_nan_samples_become_silence(self) -> None: + payload = MODULE.encode_wav([math.nan], 16_000) + _, _, _, frames = self._read(payload) + self.assertEqual(int.from_bytes(frames[0:2], "little", signed=True), 0) + + def test_empty_audio_still_produces_a_valid_wav(self) -> None: + payload = MODULE.encode_wav([], 16_000) + channels, width, rate, frames = self._read(payload) + self.assertEqual((channels, width, rate), (1, 2, 16_000)) + self.assertEqual(frames, b"") + + def test_invalid_sample_rate_fails_closed(self) -> None: + with self.assertRaises(MODULE.TtsError): + MODULE.encode_wav([0.0], 0) + + +class HealthPayloadTest(unittest.TestCase): + def test_health_declares_the_permissive_license_and_no_reference(self) -> None: + payload = MODULE.health_payload(FakeSynthesizer(), speakers=["KR"]) + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["license"], "MIT") + self.assertEqual(payload["language"], "KR") + self.assertEqual(payload["sample_rate"], 44_100) + self.assertEqual(payload["speakers"], ["KR"]) + self.assertIn("no-external-reference", payload["reference_policy"]) + + +class CliTest(unittest.TestCase): + def test_host_is_loopback_only(self) -> None: + parser = MODULE.build_parser() + with self.assertRaises(SystemExit): + parser.parse_args(["--host", "0.0.0.0"]) + + def test_defaults_target_the_reserved_sidecar_port(self) -> None: + args = MODULE.build_parser().parse_args([]) + self.assertEqual(args.port, MODULE.DEFAULT_PORT) + self.assertEqual(args.language, "KR") + + +class SynthesisPipelineTest(unittest.TestCase): + def test_request_to_wav_round_trip(self) -> None: + synthesizer = FakeSynthesizer() + request = MODULE.parse_tts_request({"text": "안녕하세요", "speed": 5}) + audio = synthesizer.synthesize(request.text, speed=request.speed) + payload = MODULE.encode_wav(audio, synthesizer.sample_rate) + self.assertEqual(synthesizer.calls, [("안녕하세요", MODULE.MAX_SPEED)]) + self.assertTrue(payload.startswith(b"RIFF")) + + +if __name__ == "__main__": + unittest.main()