Files
tts_bot/melo-test/server.py
Claude Owner 6211985730 fix(melo-test): strip standalone Hangul jamo before synthesis
MeloTTS-Korean's symbol vocab only includes precomposed Hangul
syllables. Bare Hangul Compatibility Jamo characters (ㅋㅋㅋ, ㅠㅠ, ㅎㅎ)
and Hangul Jamo block code points blow up in
`cleaned_text_to_sequence` with `KeyError: 'ㅋ'` (or similar).

Pre-clean the request text in `/tts` to drop those code point ranges
before handing the string to the model, collapsing the leftover
whitespace. If sanitization yields an empty string, return 400 with
an explanation instead of a 500 stack trace.

Also document the limitation in the README troubleshooting section.
2026-05-26 21:04:27 +09:00

130 lines
4.1 KiB
Python

"""
MeloTTS-Korean 테스트 서버.
POST /tts { "text": "...", "speed": 1.0 } -> audio/wav
GET / -> index.html (테스트 UI)
실행: python server.py (기본 0.0.0.0:7860)
"""
import os
import io
import re
import time
import tempfile
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("melo-test")
# MeloTTS 모델 핸들 (lifespan 에서 로드)
state = {"model": None, "speaker_id": None}
@asynccontextmanager
async def lifespan(_: FastAPI):
# import 가 무거우므로 lifespan 안에서 한 번만
from melo.api import TTS
log.info("MeloTTS-Korean 모델 로딩중... (첫 실행은 가중치 다운로드로 수 분 걸릴 수 있음)")
t0 = time.time()
device = os.environ.get("MELO_DEVICE", "auto")
model = TTS(language="KR", device=device)
spk = model.hps.data.spk2id
# 한국어 모델은 보통 {"KR": 0}.
# spk2id 는 dict 가 아니라 MeloTTS 의 HParams 인스턴스라
# `.get()` 이 없고 `__contains__` / `__getitem__` 만 지원함.
speaker_id = spk["KR"] if "KR" in spk else next(iter(spk.values()))
state["model"] = model
state["speaker_id"] = speaker_id
log.info("모델 로딩 완료 (%.1fs). speakers=%s, 사용=%s",
time.time() - t0, list(spk.keys()), speaker_id)
yield
state["model"] = None
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class TTSReq(BaseModel):
text: str
speed: float = 1.0
# MeloTTS-Korean 의 symbol vocab 은 완성형 음절(가, 나, ...)만 포함한다.
# 'ㅋㅋㅋ', 'ㅠㅠ', 'ㅎㅎ' 같이 한글 자모(Hangul Compatibility Jamo /
# Hangul Jamo) 가 단독으로 들어오면 cleaned_text_to_sequence 에서
# KeyError 로 죽는다. 합성 전에 미리 제거한다.
_STANDALONE_JAMO_RE = re.compile(r"[\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF]+")
def _sanitize_for_melo(text: str) -> str:
# 단독 자모를 공백으로 치환한 뒤 연속 공백을 하나로 정리.
cleaned = _STANDALONE_JAMO_RE.sub(" ", text)
return re.sub(r"\s+", " ", cleaned).strip()
@app.post("/tts")
def tts(req: TTSReq):
model = state.get("model")
if model is None:
raise HTTPException(503, "model not ready")
raw = (req.text or "").strip()
if not raw:
raise HTTPException(400, "empty text")
if len(raw) > 500:
raise HTTPException(400, "too long (<=500)")
text = _sanitize_for_melo(raw)
if not text:
raise HTTPException(
400,
"text contains only standalone Hangul jamo (e.g. ㅋㅋㅋ); "
"MeloTTS-Korean cannot synthesize these.",
)
if text != raw:
log.info("sanitized standalone jamo: %r -> %r", raw[:60], text[:60])
speed = max(0.5, min(2.0, float(req.speed or 1.0)))
fd, out_path = tempfile.mkstemp(suffix=".wav")
os.close(fd)
try:
t0 = time.time()
model.tts_to_file(text, state["speaker_id"], out_path, speed=speed)
dt_ms = int((time.time() - t0) * 1000)
with open(out_path, "rb") as f:
buf = f.read()
log.info("tts ok: %dms, %dB, text=%r", dt_ms, len(buf), text[:30])
return Response(
content=buf,
media_type="audio/wav",
headers={"X-Synth-Ms": str(dt_ms), "Cache-Control": "no-store"},
)
finally:
try:
os.unlink(out_path)
except OSError:
pass
# 정적 파일 (index.html) 서빙: 반드시 라우트 등록 뒤에 mount
HERE = os.path.dirname(os.path.abspath(__file__))
app.mount("/", StaticFiles(directory=HERE, html=True), name="static")
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("PORT", "7860"))
uvicorn.run(app, host="0.0.0.0", port=port)