설치형 로컬 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:
parent
05aa7b312e
commit
2624d49984
15 changed files with 749 additions and 33 deletions
|
|
@ -31,6 +31,12 @@ VIGNETTE_VOICE_TTS_PROVIDER=openai
|
||||||
VIGNETTE_HIGGS_TTS_URL=http://127.0.0.1:9881
|
VIGNETTE_HIGGS_TTS_URL=http://127.0.0.1:9881
|
||||||
VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS=300
|
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 사이드카를 쓴다. 외부 키가 필요 없고
|
# STT. `local_whisper`는 노트북 상주 faster-whisper 사이드카를 쓴다. 외부 키가 필요 없고
|
||||||
# 오디오가 호스트를 벗어나지 않는다. 서버는 scripts/start-local-whisper-stt.ps1 로 띄운다.
|
# 오디오가 호스트를 벗어나지 않는다. 서버는 scripts/start-local-whisper-stt.ps1 로 띄운다.
|
||||||
# `openai`는 배치라 interim 이 없고, `deepgram`은 운영 키가 필요하다.
|
# `openai`는 배치라 interim 이 없고, `deepgram`은 운영 키가 필요하다.
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,19 @@ class Settings(BaseSettings):
|
||||||
default="",
|
default="",
|
||||||
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR",
|
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",
|
default="openai",
|
||||||
validation_alias="VIGNETTE_VOICE_TTS_PROVIDER",
|
validation_alias="VIGNETTE_VOICE_TTS_PROVIDER",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||||
STT_ENDPOINT = "/audio/transcriptions"
|
STT_ENDPOINT = "/audio/transcriptions"
|
||||||
TTS_ENDPOINT = "/audio/speech"
|
TTS_ENDPOINT = "/audio/speech"
|
||||||
HIGGS_TTS_ENDPOINT = "/tts"
|
HIGGS_TTS_ENDPOINT = "/tts"
|
||||||
|
MELOTTS_TTS_ENDPOINT = "/tts"
|
||||||
|
MELOTTS_TTS_MODEL = "melotts-korean"
|
||||||
DEEPGRAM_STT_URL = "wss://api.deepgram.com/v1/listen"
|
DEEPGRAM_STT_URL = "wss://api.deepgram.com/v1/listen"
|
||||||
DEEPGRAM_STT_MODEL = "nova-3"
|
DEEPGRAM_STT_MODEL = "nova-3"
|
||||||
|
|
||||||
|
|
@ -919,6 +921,8 @@ class VoiceService:
|
||||||
local_whisper_utterance_end_ms: Optional[int] = None,
|
local_whisper_utterance_end_ms: Optional[int] = None,
|
||||||
local_whisper_finalize_timeout_seconds: Optional[float] = None,
|
local_whisper_finalize_timeout_seconds: Optional[float] = None,
|
||||||
local_whisper_connect: Optional[Callable[..., Awaitable[Any]]] = None,
|
local_whisper_connect: Optional[Callable[..., Awaitable[Any]]] = None,
|
||||||
|
melotts_base_url: Optional[str] = None,
|
||||||
|
melotts_timeout_seconds: Optional[float] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
|
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("/")
|
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._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 = (
|
self._tts_provider = (
|
||||||
tts_provider if tts_provider is not None else settings.voice_tts_provider
|
tts_provider if tts_provider is not None else settings.voice_tts_provider
|
||||||
).strip().lower()
|
).strip().lower()
|
||||||
|
|
@ -1069,6 +1087,11 @@ class VoiceService:
|
||||||
base_url=self._higgs_base_url,
|
base_url=self._higgs_base_url,
|
||||||
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
|
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:
|
async def shutdown(self) -> None:
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
|
|
@ -1077,6 +1100,9 @@ class VoiceService:
|
||||||
if self._higgs_client is not None:
|
if self._higgs_client is not None:
|
||||||
await self._higgs_client.aclose()
|
await self._higgs_client.aclose()
|
||||||
self._higgs_client = None
|
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:
|
def is_available(self) -> bool:
|
||||||
"""마이크 캐스케이드(STT+TTS) 전체 가용 여부."""
|
"""마이크 캐스케이드(STT+TTS) 전체 가용 여부."""
|
||||||
|
|
@ -1240,6 +1266,8 @@ class VoiceService:
|
||||||
)
|
)
|
||||||
|
|
||||||
def tts_available(self, voice: VoicePreset | None = None) -> bool:
|
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)):
|
if self._higgs_enabled() and (voice is None or self._should_use_higgs_tts(voice)):
|
||||||
return True
|
return True
|
||||||
if self._poc_sample_tts_available():
|
if self._poc_sample_tts_available():
|
||||||
|
|
@ -1247,6 +1275,8 @@ class VoiceService:
|
||||||
return bool(self._api_key)
|
return bool(self._api_key)
|
||||||
|
|
||||||
def tts_provider(self) -> str:
|
def tts_provider(self) -> str:
|
||||||
|
if self._melotts_enabled():
|
||||||
|
return "melotts"
|
||||||
if self._higgs_enabled():
|
if self._higgs_enabled():
|
||||||
return "higgs"
|
return "higgs"
|
||||||
if self._poc_sample_tts_available():
|
if self._poc_sample_tts_available():
|
||||||
|
|
@ -1260,6 +1290,8 @@ class VoiceService:
|
||||||
return "unavailable"
|
return "unavailable"
|
||||||
|
|
||||||
def tts_provider_for_voice(self, voice: VoicePreset) -> str:
|
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):
|
if self._should_use_higgs_tts(voice):
|
||||||
return "higgs"
|
return "higgs"
|
||||||
if self._should_use_poc_sample_tts(voice):
|
if self._should_use_poc_sample_tts(voice):
|
||||||
|
|
@ -1267,14 +1299,27 @@ class VoiceService:
|
||||||
return "openai" if self._api_key else "unavailable"
|
return "openai" if self._api_key else "unavailable"
|
||||||
|
|
||||||
def tts_model_for_voice(self, voice: VoicePreset) -> str:
|
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
|
return HIGGS_TTS_MODEL if self._should_use_higgs_tts(voice) else TTS_MODEL
|
||||||
|
|
||||||
def tts_media_type_for_voice(self, voice: VoicePreset) -> str:
|
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:
|
def _higgs_enabled(self) -> bool:
|
||||||
return self._tts_provider == "higgs" and self._environment == "dev"
|
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:
|
def _should_use_higgs_tts(self, voice: VoicePreset) -> bool:
|
||||||
# 현재 권리 안전한 synthetic reference는 P1 서연 프리셋만 보유한다.
|
# 현재 권리 안전한 synthetic reference는 P1 서연 프리셋만 보유한다.
|
||||||
return self._higgs_enabled() and voice.preset == POC_SAMPLE_TTS_PRESET
|
return self._higgs_enabled() and voice.preset == POC_SAMPLE_TTS_PRESET
|
||||||
|
|
@ -1387,6 +1432,10 @@ class VoiceService:
|
||||||
text = speakable_text(text)
|
text = speakable_text(text)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
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):
|
if self._should_use_higgs_tts(voice):
|
||||||
async for chunk in self._synthesize_higgs_tts(text, voice):
|
async for chunk in self._synthesize_higgs_tts(text, voice):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
@ -1441,6 +1490,41 @@ class VoiceService:
|
||||||
if chunk:
|
if chunk:
|
||||||
yield TTSChunk(audio=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(
|
async def _synthesize_higgs_tts(
|
||||||
self, text: str, voice: VoicePreset
|
self, text: str, voice: VoicePreset
|
||||||
) -> AsyncIterator[TTSChunk]:
|
) -> AsyncIterator[TTSChunk]:
|
||||||
|
|
@ -1529,6 +1613,7 @@ __all__ = [
|
||||||
"TranscriptWord",
|
"TranscriptWord",
|
||||||
"StreamingTranscriptEvent",
|
"StreamingTranscriptEvent",
|
||||||
"DeepgramStreamingSession",
|
"DeepgramStreamingSession",
|
||||||
|
"MELOTTS_TTS_MODEL",
|
||||||
"LocalWhisperStreamingSession",
|
"LocalWhisperStreamingSession",
|
||||||
"EndOfTurnDecision",
|
"EndOfTurnDecision",
|
||||||
"TTSChunk",
|
"TTSChunk",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from .services.voice import (
|
||||||
HIGGS_TTS_ENDPOINT,
|
HIGGS_TTS_ENDPOINT,
|
||||||
HIGGS_TTS_MODEL,
|
HIGGS_TTS_MODEL,
|
||||||
DEEPGRAM_STT_MODEL,
|
DEEPGRAM_STT_MODEL,
|
||||||
|
MELOTTS_TTS_MODEL,
|
||||||
DeepgramStreamingSession,
|
DeepgramStreamingSession,
|
||||||
LocalWhisperStreamingSession,
|
LocalWhisperStreamingSession,
|
||||||
VoicePreset,
|
VoicePreset,
|
||||||
|
|
@ -864,6 +865,63 @@ class LocalWhisperStreamingTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertFalse(connected)
|
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):
|
class LocalWhisperProviderSelectionTest(unittest.TestCase):
|
||||||
def test_local_whisper_needs_no_api_key(self) -> None:
|
def test_local_whisper_needs_no_api_key(self) -> None:
|
||||||
service = VoiceService(api_key="", stt_provider="local_whisper")
|
service = VoiceService(api_key="", stt_provider="local_whisper")
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ dataset / dataset_item / annotation_round / annotation / export_manifest
|
||||||
| DB | **PostgreSQL 16** + pgvector(RAG 통합), NAS 영구볼륨, RLS | Timescale은 hypertable 선택 확장 |
|
| DB | **PostgreSQL 16** + pgvector(RAG 통합), NAS 영구볼륨, RLS | Timescale은 hypertable 선택 확장 |
|
||||||
| 컨테이너 | **Docker Compose** 단일 호스트 모놀리식(6 컨테이너), Caddy 리버스프록시(`flush_interval -1`) | 이식=compose+secrets, 클라우드 VM(국내 리전) `docker compose up` |
|
| 컨테이너 | **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 |
|
| 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 철회 즉시 무효화) |
|
| 인증 | **OAuth 2.1 Auth Code + PKCE(S256)** BFF, Google OIDC 1차, Authlib | 토큰 Redis만, HttpOnly `__Host-` 쿠키, stateful 세션(IRB 철회 즉시 무효화) |
|
||||||
| 외부노출 | **Cloudflare named tunnel** + SSE는 Tailscale Funnel 분리 | 인바운드 포트 0 |
|
| 외부노출 | **Cloudflare named tunnel** + SSE는 Tailscale Funnel 분리 | 인바운드 포트 0 |
|
||||||
| 안전 | Presidio PII 마스킹 + NeMo rail + 한국어 자살분류 + 결정론 게이트 | Llama Guard 한국어 미지원 보강 |
|
| 안전 | Presidio PII 마스킹 + NeMo rail + 한국어 자살분류 + 결정론 게이트 | Llama Guard 한국어 미지원 보강 |
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`decisions/backend-node-transition.md`](./decisions/backend-node-transition.md) | 백엔드 언어 방향(FastAPI 유지·Node 계약 우선 전환) |
|
| [`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/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 경계, 전진 복구·롤백 결정 |
|
| [`decisions/outcome-alliance-measurement-ledger.md`](./decisions/outcome-alliance-measurement-ledger.md) | Outcome & Alliance append-only 측정 원장, source/perspective 경계, 전진 복구·롤백 결정 |
|
||||||
|
|
||||||
## 🧪 Phase 3 파일럿 (forward — 아직 미실행)
|
## 🧪 Phase 3 파일럿 (forward — 아직 미실행)
|
||||||
|
|
|
||||||
21
docs/TODO.md
21
docs/TODO.md
|
|
@ -62,8 +62,8 @@
|
||||||
남은 것: NAS용 9100은 실사용 세션 7개가 있어 미접촉이라 (2)는 다음 재기동 때 반영된다.
|
남은 것: NAS용 9100은 실사용 세션 7개가 있어 미접촉이라 (2)는 다음 재기동 때 반영된다.
|
||||||
- [ ] **재부팅 후 watchdog smoke** — 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn` 실측.
|
- [ ] **재부팅 후 watchdog smoke** — 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn` 실측.
|
||||||
DNS 개통 후 `api-vnet.18ka.net`을 `-AdditionalPublicHealthUrls`로 명시 추가.
|
DNS 개통 후 `api-vnet.18ka.net`을 `-AdditionalPublicHealthUrls`로 명시 추가.
|
||||||
- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 STT는 노트북 상주
|
- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주
|
||||||
faster-whisper, TTS는 노트북 Higgs를 쓴다. 이전에는 코드에 로컬 STT 경로가 아예 없었고
|
로컬 모델로 쓴다(STT faster-whisper, TTS는 아래 항목대로 MeloTTS). 이전에는 코드에 로컬 STT 경로가 아예 없었고
|
||||||
(`voice_stt_provider`가 `openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다.
|
(`voice_stt_provider`가 `openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다.
|
||||||
`scripts/local-whisper-stt-server.py` 사이드카(Higgs와 같은 loopback 상주 방식)와
|
`scripts/local-whisper-stt-server.py` 사이드카(Higgs와 같은 loopback 상주 방식)와
|
||||||
`voice_stt_provider=local_whisper` 경로를 추가했다. 오디오는 호스트를 벗어나지 않고 외부 STT 키가
|
`voice_stt_provider=local_whisper` 경로를 추가했다. 오디오는 호스트를 벗어나지 않고 외부 STT 키가
|
||||||
|
|
@ -77,11 +77,18 @@
|
||||||
**발견 2:** G7 종료 체커가 `expected_stt_provider == "deepgram"`을 하드코딩하고 있었다. 이는 결정
|
**발견 2:** G7 종료 체커가 `expected_stt_provider == "deepgram"`을 하드코딩하고 있었다. 이는 결정
|
||||||
기록이 아니라 벤더 한 줄이었으므로, 운영하기로 한 provider 허용목록으로 바꿨다
|
기록이 아니라 벤더 한 줄이었으므로, 운영하기로 한 provider 허용목록으로 바꿨다
|
||||||
(`ALLOWED_STT_PROVIDERS`/`ALLOWED_TTS_PROVIDERS`). 선언/실제 불일치 차단은 그대로 유지한다.
|
(`ALLOWED_STT_PROVIDERS`/`ALLOWED_TTS_PROVIDERS`). 선언/실제 불일치 차단은 그대로 유지한다.
|
||||||
- [ ] **Higgs 운영 사용 라이선스 결정** — 소유자는 TTS를 노트북 Higgs로 쓰기로 결정했으나,
|
- [x] **노트북 로컬 음성 스택 TTS 구현 (MeloTTS)** — Higgs는 연구/비상업 라이선스라 `config.py`가
|
||||||
`apps/api/app/config.py`가 `environment != dev`에서 `VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다
|
운영에서 차단하고 있었고, 그 가드를 푸는 건 법적 판단이라 코드로 결정할 수 없었다. 그래서 상업 사용이
|
||||||
(Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는 법적 판단이라 임의로 제거하지 않았다.
|
허용된 설치형을 다시 찾아 **MeloTTS Korean(MIT)** 으로 바꿨다. 가드를 건드릴 필요가 사라졌다.
|
||||||
산학협력 연구·교육 용도가 해당 라이선스 범위에 들어가는지 확인하고 `decisions/`에 근거를 남긴 뒤
|
`scripts/melotts-server.py` loopback 사이드카 + `voice_tts_provider=melotts` 경로를 추가했고,
|
||||||
가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는 OpenAI `gpt-4o-mini-tts`로 유지한다.
|
사전학습 다화자 모델이라 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,
|
- [ ] **음성 캐스케이드 live** — Deepgram streaming adapter와 interim/final·word timestamp, bounded event queue,
|
||||||
운영 10MiB 사전 거부와 실제 PostgreSQL 동시 철회 직렬화는 코드/내부 회귀까지 완료했다. 종료에는 운영
|
운영 10MiB 사전 거부와 실제 PostgreSQL 동시 철회 직렬화는 코드/내부 회귀까지 완료했다. 종료에는 운영
|
||||||
key·quota를 사용한 live interim/final, 기대 provider/model과 일치하는 authenticated public WSS ready,
|
key·quota를 사용한 live interim/final, 기대 provider/model과 일치하는 authenticated public WSS ready,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,38 @@
|
||||||
# 로컬 음성 스택 결정 — 노트북 faster-whisper STT + Higgs TTS
|
# 로컬 음성 스택 결정 — 노트북 faster-whisper STT + MeloTTS TTS
|
||||||
|
|
||||||
Date: 2026-08-08
|
Date: 2026-08-08 (같은 날 TTS 재선정으로 개정)
|
||||||
Status: accepted (STT 구현 완료 · TTS 운영 사용은 라이선스 확인 대기)
|
Status: accepted — STT·TTS 모두 구현·실측 완료, 라이선스 제약 없음
|
||||||
Owner decision: 윤찬
|
Owner decision: 윤찬
|
||||||
|
|
||||||
## 결정
|
## 결정
|
||||||
|
|
||||||
음성 캐스케이드의 양쪽을 **개발 노트북에 상주하는 로컬 모델**로 운영한다.
|
음성 캐스케이드의 양쪽을 **개발 노트북에 상주하는 로컬 모델**로 운영한다.
|
||||||
|
둘 다 **허용적 라이선스(MIT)** 라 운영 사용에 제약이 없다.
|
||||||
|
|
||||||
- **STT(듣기)**: faster-whisper (`local_whisper` provider)
|
- **STT(듣기)**: faster-whisper — MIT (`local_whisper` provider)
|
||||||
- **TTS(말하기)**: Higgs Audio v3 TTS 4B
|
- **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로 분기하지 않는다.
|
(`send_audio`/`finish`/`abort`)이라 WebSocket 라우트는 provider로 분기하지 않는다.
|
||||||
- `VIGNETTE_VOICE_STT_PROVIDER=local_whisper` + `VIGNETTE_LOCAL_WHISPER_*` 설정.
|
- `VIGNETTE_VOICE_STT_PROVIDER=local_whisper` + `VIGNETTE_LOCAL_WHISPER_*` 설정.
|
||||||
- 실행: `scripts/start-local-whisper-stt.ps1`.
|
- 실행: `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)
|
## 실측 증거 (2026-08-08)
|
||||||
|
|
||||||
|
|
@ -61,6 +87,39 @@ Owner decision: 윤찬
|
||||||
`small` + CPU int8에서 한 글자(`안녕하세요`→`안녕하세여`) 차이였다. 회귀는 사이드카 37/37,
|
`small` + CPU int8에서 한 글자(`안녕하세요`→`안녕하세여`) 차이였다. 회귀는 사이드카 37/37,
|
||||||
API 전체 908 passed, gateway 58, G7 checker 23, ruff clean이다.
|
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`이 없어
|
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`는 조용히 강등하지
|
(`--self-check`)로 분리하고 `auto`에서 CPU(int8)로 폴백한다. 명시적 `--device cuda`는 조용히 강등하지
|
||||||
않는다. **cuDNN 9를 설치하면** GPU float16으로 올라가고 `large-v3`도 실시간권에 들어온다. 설치는
|
않는다. **cuDNN 9를 설치하면** GPU float16으로 올라가고 `large-v3`도 실시간권에 들어온다. 설치는
|
||||||
환경 변경이라 소유자 판단으로 남겼다.
|
환경 변경이라 소유자 판단으로 남겼다.
|
||||||
2. **Higgs 운영 사용은 아직 막혀 있다.** `apps/api/app/config.py`가 `environment != dev`에서
|
2. **Higgs 가드는 그대로 둔다.** `apps/api/app/config.py`는 계속 `environment != dev`에서
|
||||||
`VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다(Higgs Audio v3 4B 연구/비상업 라이선스). 이 가드는
|
`VIGNETTE_VOICE_TTS_PROVIDER=higgs`를 차단한다. MeloTTS 채택으로 이 가드를 풀 이유가 없어졌다.
|
||||||
법적 판단이라 코드에서 임의로 제거하지 않았다. 산학협력 연구·교육 용도가 라이선스 범위에 들어가는지
|
Higgs 를 굳이 운영에서 쓰고 싶어지면 그때 라이선스 근거를 이 문서에 추가하고 가드를 조정한다.
|
||||||
확인하고 그 근거를 이 문서에 추가한 뒤 가드를 조정한다. 그 전까지 Higgs는 dev 전용이고 운영 TTS는
|
|
||||||
OpenAI `gpt-4o-mini-tts`를 유지한다.
|
|
||||||
3. **CPU 폴백 성능.** CPU int8에서는 `small`이 현실적이다. `large-v3`는 CPU에서 실시간 스트리밍에
|
3. **CPU 폴백 성능.** CPU int8에서는 `small`이 현실적이다. `large-v3`는 CPU에서 실시간 스트리밍에
|
||||||
맞추기 어렵다.
|
맞추기 어렵다.
|
||||||
|
|
||||||
|
|
@ -83,7 +140,7 @@ API 전체 908 passed, gateway 58, G7 checker 23, ruff clean이다.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram")
|
ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram")
|
||||||
ALLOWED_TTS_PROVIDERS = ("higgs", "openai")
|
ALLOWED_TTS_PROVIDERS = ("melotts", "higgs", "openai")
|
||||||
```
|
```
|
||||||
|
|
||||||
게이트를 약화시키지 않았다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어 목록에 없고,
|
게이트를 약화시키지 않았다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어 목록에 없고,
|
||||||
|
|
|
||||||
|
|
@ -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>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>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>격리 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>비-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>공개 워치독 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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1
|
||||||
# 로컬 Higgs Audio v3 P1 음성까지 함께 연결
|
# 로컬 Higgs Audio v3 P1 음성까지 함께 연결
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\dev-up.ps1 -UseHiggsVoice
|
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 키 불필요)
|
# 노트북 상주 faster-whisper STT 사이드카 (외부 STT 키 불필요)
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-local-whisper-stt.ps1 -Model small
|
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-local-whisper-stt.ps1 -Model small
|
||||||
# 그 뒤 API 에 VIGNETTE_VOICE_STT_PROVIDER=local_whisper 를 준다.
|
# 그 뒤 API 에 VIGNETTE_VOICE_STT_PROVIDER=local_whisper 를 준다.
|
||||||
|
|
@ -166,6 +170,7 @@ npm install
|
||||||
| `AUTH_NEW_USER_DEFAULT_STATUS` | `pending` | Google/SAML 신규 사용자의 기본 승인 상태. `dev:` 로그인은 로컬/E2E 편의를 위해 자동 승인 |
|
| `AUTH_NEW_USER_DEFAULT_STATUS` | `pending` | Google/SAML 신규 사용자의 기본 승인 상태. `dev:` 로그인은 로컬/E2E 편의를 위해 자동 승인 |
|
||||||
| `AUTH_EMAIL_COHORT_MAP` | `{}` | 특정 이메일을 cohort id로 매핑한다. 값은 comma-separated 문자열도 허용 |
|
| `AUTH_EMAIL_COHORT_MAP` | `{}` | 특정 이메일을 cohort id로 매핑한다. 값은 comma-separated 문자열도 허용 |
|
||||||
| `AUTH_DOMAIN_COHORT_MAP` | `{}` | 이메일/Google hosted domain을 cohort id로 매핑한다. Google/SAML/dev-login 세션 `cohort_ids`에 반영 |
|
| `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 경로 |
|
| `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_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 계약 |
|
| `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_KEEPALIVE_SECONDS` / `DEEPGRAM_FINALIZE_TIMEOUT_SECONDS` | `4` / `15` | streaming keepalive와 final 대기 상한 |
|
||||||
| `DEEPGRAM_MIP_OPT_OUT` | `true` | Deepgram model improvement program opt-out query 기본값 |
|
| `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_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_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_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`로 띄운다 |
|
| `VIGNETTE_LOCAL_WHISPER_STT_URL` | `ws://127.0.0.1:9882/v1/listen` | 로컬 whisper 사이드카. `scripts/start-local-whisper-stt.ps1`로 띄운다 |
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,15 @@ MINIMUM_SOAK_SECONDS = 3_000.0
|
||||||
|
|
||||||
# 이 게이트는 특정 벤더가 아니라 **운영하기로 결정한 provider** 를 강제한다.
|
# 이 게이트는 특정 벤더가 아니라 **운영하기로 결정한 provider** 를 강제한다.
|
||||||
# 2026-08-08 소유자 결정: STT 는 노트북 상주 faster-whisper(`local_whisper`),
|
# 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 계약을 만족할 수 없어
|
# 목록은 닫혀 있다. 배치 STT(`openai`)는 interim/final 계약을 만족할 수 없어
|
||||||
# 여기 들어오지 못한다. 어떤 경우에도 `expected_* == ready_*` 결속은 유지되므로
|
# 여기 들어오지 못한다. 어떤 경우에도 `expected_* == ready_*` 결속은 유지되므로
|
||||||
# 선언한 provider 와 실제로 돈 provider 가 다르면 계속 실패한다.
|
# 선언한 provider 와 실제로 돈 provider 가 다르면 계속 실패한다.
|
||||||
ALLOWED_STT_PROVIDERS = ("local_whisper", "deepgram")
|
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]:
|
def load_json(path: Path, errors: list[str], label: str) -> dict[str, Any]:
|
||||||
|
|
|
||||||
253
scripts/melotts-server.py
Normal file
253
scripts/melotts-server.py
Normal 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
93
scripts/start-melotts.ps1
Normal 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"
|
||||||
|
|
@ -155,10 +155,10 @@ def public_soak() -> dict[str, object]:
|
||||||
"ready_stt_provider": "local_whisper",
|
"ready_stt_provider": "local_whisper",
|
||||||
"expected_stt_model": "large-v3",
|
"expected_stt_model": "large-v3",
|
||||||
"ready_stt_model": "large-v3",
|
"ready_stt_model": "large-v3",
|
||||||
"expected_tts_provider": "higgs",
|
"expected_tts_provider": "melotts",
|
||||||
"ready_tts_provider": "higgs",
|
"ready_tts_provider": "melotts",
|
||||||
"expected_tts_model": "higgs-audio-v3-tts-4b",
|
"expected_tts_model": "melotts-korean",
|
||||||
"ready_tts_model": "higgs-audio-v3-tts-4b",
|
"ready_tts_model": "melotts-korean",
|
||||||
"turns_attempted": 10,
|
"turns_attempted": 10,
|
||||||
"turns_succeeded": 10,
|
"turns_succeeded": 10,
|
||||||
"turn_transcript_metrics": metrics,
|
"turn_transcript_metrics": metrics,
|
||||||
|
|
@ -287,12 +287,12 @@ class G7ExternalProofTests(unittest.TestCase):
|
||||||
self.assertGreaterEqual(gain["held_out_participants"], 30)
|
self.assertGreaterEqual(gain["held_out_participants"], 30)
|
||||||
|
|
||||||
def test_decided_local_stack_is_accepted(self) -> None:
|
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] = []
|
errors: list[str] = []
|
||||||
voice = public_soak()
|
voice = public_soak()
|
||||||
self.assertEqual(voice["expected_stt_provider"], "local_whisper")
|
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.checker.validate_public_soak(voice, errors)
|
||||||
self.assertEqual([], errors)
|
self.assertEqual([], errors)
|
||||||
|
|
||||||
|
|
|
||||||
139
scripts/test_melotts_server.py
Normal file
139
scripts/test_melotts_server.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue