vignette/scripts/test_melotts_server.py
Yun Chan 2624d49984 설치형 로컬 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.
2026-08-08 09:29:57 +09:00

139 lines
5.5 KiB
Python

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