설치형 로컬 TTS를 MeloTTS 한국어(MIT)로 채택하고 엔드포인트로 연결

Higgs Audio v3 는 연구/비상업 라이선스라 config.py 가 environment != dev 에서
차단하고 있었다. 그 가드를 푸는 건 법적 판단이라 코드로 결정할 수 없어서,
상업 사용이 허용된 설치형을 다시 찾아 MeloTTS Korean 으로 바꿨다. 결과적으로
가드를 건드릴 필요 자체가 사라졌다 — Higgs 가드는 그대로 두고 provider 만
melotts 로 두면 운영에서도 동작한다.

검토 결과:
- MeloTTS   MIT       한국어 지원  -> 채택. CPU 실시간, 사전학습 다화자
- Kokoro-82M Apache2.0 한국어 없음  -> 탈락. 공식 VOICES.md 언어 목록에 부재
- Piper      GPL                   -> 탈락
- XTTS-v2 / Fish Speech 비상업      -> 탈락. Higgs 와 같은 문제

사전학습 다화자 모델이라 실존 인물 reference 를 쓰지 않는다. Higgs 경로가
P1 프리셋 한정이던 이유가 없으므로 모든 페르소나 프리셋에 적용된다.

구현:
- scripts/melotts-server.py  loopback HTTP 사이드카(/health, POST /tts -> WAV)
- voice_tts_provider=melotts 경로와 VIGNETTE_MELOTTS_TTS_* 설정
- scripts/start-melotts.ps1  런처(설치 순서 안내 포함)

실측:
- CPU 정상 상태 RTF 0.27~0.28(실시간 3.6배). 첫 실행 13.25 는 모델 다운로드
- POST /tts 200, WAV 350,566 bytes, 3.61s, 헤더 provider/model/license
- 빈 텍스트 422, 미지 경로 404 로 fail-closed
- 왕복 검증: MeloTTS 합성음을 로컬 faster-whisper 가 완전 일치 전사
  "그렇게 느끼셨군요. 조금 더 이야기해 주실 수 있을까요?" (word timestamp 8개)

설치 함정 3가지를 decisions/local-voice-stack.md 에 남겼다.
librosa 0.9.1 의 pkg_resources(setuptools<81), MeloTTS 가 언어와 무관하게
임포트하는 일본어 unidic 사전, Windows 한국어 g2p 의 eunjeon.

G7 게이트의 TTS 허용목록에 melotts 를 추가했다. 선언/실제 불일치 차단과
배치 STT 배제는 그대로다.

검증: API 914 passed, 사이드카 melotts 16/16 + whisper 37/37, SSOT FAIL 0, ruff clean.
This commit is contained in:
Yun Chan 2026-08-08 09:29:57 +09:00
parent 05aa7b312e
commit 2624d49984
15 changed files with 749 additions and 33 deletions

View file

@ -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`은 운영 키가 필요하다.

View file

@ -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",
)

View file

@ -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",

View file

@ -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")

View file

@ -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 한국어 미지원 보강 |

View file

@ -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 — 아직 미실행)

View file

@ -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,

View file

@ -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 계약을 만족할 수 없어 목록에 없고,

View file

@ -1122,7 +1122,7 @@
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code> / DB mode with local <code>DATABASE_URL</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys, and DB readiness (<code>current_user=vignette</code>). DB mode can additionally check app-role DSN with <code>--require-app-role</code> and now verifies session read-model columns including <code>app.turns</code> voice metadata columns(<code>audio_ref</code>/<code>silence_ms</code>/<code>speech_rate</code>/<code>barge_in</code>/<code>provider_events</code>) plus worksheet review columns.</td></tr>
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
<tr><td>격리 NAS 프리뷰 · 회기 E2E 자동화</td><td><code>http://100.116.83.60:8088</code> / <code>vignette-e2e</code> 매일 04:30 KST / <a href="./ops/nas-preview-deployment-evidence-2026-08-07.md">배포 증거</a> / <a href="./ops/evidence/nas-preview-current-deploy-2026-08-07.json">기계 판독 증거</a></td><td>current SHA <code>6030a677…c611</code>을 전용 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 증거는 별도다.</td></tr>
<tr><td>노트북 로컬 음성 스택 — faster-whisper STT</td><td><code>scripts/local-whisper-stt-server.py</code> · <code>scripts/start-local-whisper-stt.ps1</code> · <code>voice_stt_provider=local_whisper</code></td><td>2026-08-08 소유자 결정으로 STT를 노트북 상주 faster-whisper로, TTS를 노트북 Higgs로 정했다. 이전에는 로컬 STT 경로가 코드에 없었고 interim/final 스트리밍이 Deepgram 전용이었다. Higgs와 같은 loopback 사이드카를 추가해 오디오가 호스트를 벗어나지 않고 외부 STT 키도 필요 없다. <b>실측</b>: 저장소 합성 시드 8.72초에서 interim <code>9</code>·final <code>5</code>·word timestamp 12개 present, 전사는 원문과 한 글자 차이. 회귀 사이드카 <code>37/37</code>, API 전체 <code>908 passed</code>, gateway <code>58</code>, G7 checker <code>23</code>, ruff clean. 이 노트북은 CUDA는 보이지만 cuDNN 부재로 GPU 추론이 <b>네이티브 크래시</b>라 디바이스 확인을 자식 프로세스(<code>--self-check</code>)로 분리하고 CPU 폴백을 넣었다. cuDNN 9 설치는 소유자 판단으로 남겼다. G7 체커의 <code>deepgram</code> 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다.</td></tr>
<tr><td>노트북 로컬 음성 스택 — faster-whisper STT + MeloTTS TTS</td><td><code>scripts/local-whisper-stt-server.py</code> · <code>scripts/start-local-whisper-stt.ps1</code> · <code>voice_stt_provider=local_whisper</code></td><td>2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주 로컬 모델로 정했다. TTS는 처음 Higgs로 잡았으나 연구/비상업 라이선스라 <code>config.py</code>가 운영에서 차단하고 있었고 그 가드를 푸는 건 법적 판단이라, 상업 사용이 허용된 <b>MeloTTS Korean(MIT)</b>으로 바꿔 가드 자체를 불필요하게 만들었다. Kokoro-82M은 Apache 2.0이지만 공식 <code>VOICES.md</code><b>한국어가 없어</b> 탈락했다. 이전에는 로컬 STT 경로가 코드에 없었고 interim/final 스트리밍이 Deepgram 전용이었다. Higgs와 같은 loopback 사이드카를 추가해 오디오가 호스트를 벗어나지 않고 외부 STT 키도 필요 없다. <b>실측</b>: 저장소 합성 시드 8.72초에서 interim <code>9</code>·final <code>5</code>·word timestamp 12개 present, 전사는 원문과 한 글자 차이. 회귀 사이드카 <code>37/37</code>, API 전체 <code>908 passed</code>, gateway <code>58</code>, G7 checker <code>23</code>, ruff clean. 이 노트북은 CUDA는 보이지만 cuDNN 부재로 GPU 추론이 <b>네이티브 크래시</b>라 디바이스 확인을 자식 프로세스(<code>--self-check</code>)로 분리하고 CPU 폴백을 넣었다. cuDNN 9 설치는 소유자 판단으로 남겼다. MeloTTS는 CPU 정상 상태 RTF <code>0.27~0.28</code>(실시간 3.6배), <code>POST /tts</code> 200·WAV 350,566 bytes·3.61s, 빈 텍스트 422·미지 경로 404이며, <b>합성음을 로컬 STT가 완전 일치 전사</b>하는 왕복 검증을 통과했다(사이드카 16/16, API 914 passed). G7 체커의 <code>deepgram</code> 하드코딩도 운영 provider 허용목록으로 교정했고 선언/실제 불일치 차단은 유지한다. Higgs 운영 사용은 라이선스 가드가 남아 dev 전용이다.</td></tr>
<tr><td>비-secure origin 회기 리뷰 크래시 수정</td><td><code>apps/web/src/lib/uuid.ts</code> · <code>apps/web/e2e/insecure-context-uuid.spec.ts</code></td><td>격리 NAS 프리뷰(평문 HTTP·비-localhost)에서 회기 리뷰 라우트가 <code>crypto.randomUUID is not a function</code>으로 error boundary에 잡혔다. 이 API는 secure context 전용인데 제품 코드 18곳이 fallback 없이 호출했고 <code>RuptureRepairCard</code>는 렌더 시점 호출이라 화면 전체가 죽었다. 릴리스 게이트는 localhost 후보 스택에서만 돌아 미검출. <code>randomUuid()</code>로 통일하고 fallback도 <code>getRandomValues</code> 우선으로 예측 불가능성을 유지했다. 회귀 <code>6/6</code>(직접 호출 0건 검사 포함), typecheck·build 통과. <b>NAS 프리뷰에는 아직 미배포</b>이며 배포된 SHA <code>6030a677…c611</code>은 여전히 결함 빌드다.</td></tr>
<tr><td>공개 워치독 engine readiness 사각지대(해결)</td><td><code>scripts/watch-public-runtime.ps1</code> · <code>scripts/start-public-runtime.ps1</code> · <code>apps/api/engine_gateway/gateway.py</code></td><td>2026-08-07 22:0x KST 소유자 승인으로 9099를 재기동해 복구했다. 재기동 직후 <code>/ready?force=true</code> 200 <code>ok:true·detail:OK</code>, API 8001과 공개 <code>api-vignette.chanpaca.net</code> 모두 <code>status:ok·engine:true·engine_detail:OK</code>다. 원인은 16:06부터 상주한 9099 프로세스 하나였다(CLI·코드·cwd는 정상 — 동일 인자 직접 실행과 <code>EngineSession</code> 재현이 <code>"OK"</code>를 반환). 재발 방지 3건: (1) 워치독·기동 스크립트의 engine 판정을 <code>/health</code>(프로세스 liveness)에서 <code>/ready</code>(실제 생성)로 바꾸고 API 판정에 <code>engine</code>을 추가, shared secret 인스턴스용 토큰 헤더와 503 본문 로깅 포함. (2) 게이트웨이가 자식 <code>claude -p</code>의 stderr를 상시 드레인해(PIPE 미독 시 자식 블록도 차단) 실패 detail에 <code>exit</code>·stderr를 붙인다 — 실증: <code>empty engine response (error: unknown option '--vignette-nonexistent-flag-xyz')</code>. (3) 기동 시 게이트웨이 로그 회전, <code>Stop-UvicornByPort</code><code>Name -like python*</code> 추가(호출자 자기 자신 종료 방지). 검증: <code>pytest engine_gateway</code> 58/58, 워치독 <code>-CheckOnly</code> healthy 5/5, 장애 재현(9299·<code>CLAUDE_BIN</code> 부재)에서 <code>/health</code><code>ok:true</code>로 통과하고 <code>/ready</code>는 503으로 검출되어 워치독이 <code>unhealthy (1/3)</code> + 503 본문을 남겼다. <b>NAS용 9100(06:27 상주)은 실사용 세션 7개가 있어 미접촉</b>이라 게이트웨이 stderr 개선은 다음 재기동 때 반영된다.</td></tr>
<tr><td>G8 실제 receipt-bound image rollback</td><td><code>scripts/launch-nas-preview-g8-helpers.py</code> / <code>scripts/serve-nas-preview-rollback-executor.py</code> / <code>scripts/probe-nas-preview-g8-rollback.py</code> / <a href="./ops/nas-preview-g8-rollback-proof-runbook.md">런북</a> / <a href="./ops/evidence/nas-preview-g8-actual-rollback-2026-08-07.json">기계 판독 증거</a></td><td>격리 NAS에서 별도 executor(18149)·control-plane(8018) helper로 실제 image rollback 2회를 실행했다. receipt <code>nas-g8-723eeef2…</code>로 previous <code>79ec…4450</code>/<code>c530…2f28</code>, receipt <code>nas-g8-2738846c…</code>로 current <code>52e0…8b2d</code>/<code>6fdb…f215</code>를 활성화했다. release gate·approval 각각 2회 멱등, <code>audit.ci_lifecycle_event</code> 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로 고정했다. <code>down</code>/<code>volume rm</code>/<code>prune</code> 미실행, 공개 런타임 미접촉.</td></tr>

View file

@ -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`로 띄운다 |

View file

@ -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]:

253
scripts/melotts-server.py Normal file
View file

@ -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())

93
scripts/start-melotts.ps1 Normal file
View file

@ -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"

View file

@ -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)

View file

@ -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()