설치형 로컬 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

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