vignette/scripts/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

253 lines
8.8 KiB
Python

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