test: add MeloTTS-Korean browser test harness

melo-test/ contains a minimal FastAPI server + static HTML UI for
auditioning MeloTTS-Korean before wiring it into the bot:

- server.py: POST /tts -> wav, model loaded once at startup
- index.html: textarea + presets + audio player, shows synth latency
- requirements.txt / README.md: setup steps

Directory is melo-test/ because the repo .gitignore already excludes
test/. Discord-bot integration is unchanged in this branch; this is a
local audition harness only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Owner
2026-05-26 15:16:54 +09:00
parent 2002b1cc12
commit 124d9dc99d
5 changed files with 298 additions and 0 deletions

5
melo-test/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
*.wav
*.mp3

75
melo-test/README.md Normal file
View File

@@ -0,0 +1,75 @@
# MeloTTS-Korean 테스트 환경
디스코드 봇에 붙이기 전에 MeloTTS-Korean 음질·지연을 브라우저에서 바로 확인하기 위한 최소 환경.
## 구성
- `server.py` — FastAPI 서버 (`POST /tts` → wav 반환, `/` → 정적 페이지)
- `index.html` — 텍스트 입력 + 합성 + 재생 UI
- `requirements.txt` — Python 의존성
## 사전 요구
- Python 3.9 ~ 3.11 (MeloTTS 권장 범위)
- ffmpeg 불필요 (테스트는 wav 그대로 재생)
- 첫 실행 시 모델 가중치(수백 MB) 다운로드. 시간/디스크 여유 확인
- CPU 만으로 동작. GPU 있으면 자동으로 사용 (`MELO_DEVICE=cuda` 명시 가능)
## 설치 & 실행
```bash
# 1. 가상환경 (권장)
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 2. 의존성
pip install --upgrade pip
pip install -r requirements.txt
# (선택) 일본어 모델을 함께 쓸 경우에만 필요
# python -m unidic download
# 3. 서버 실행
python server.py
# 또는 포트 변경: PORT=8000 python server.py
```
서버가 뜨면 브라우저에서 다음 주소 열기:
```
http://localhost:7860
```
텍스트 입력 → `합성` 클릭 → 오디오 플레이어에서 즉시 재생.
응답 헤더 `X-Synth-Ms` 로 합성 소요 시간(ms) 확인 가능.
## API
```http
POST /tts
Content-Type: application/json
{ "text": ".", "speed": 1.0 }
```
응답: `audio/wav` 바이너리, 헤더 `X-Synth-Ms: 1234`.
## 환경 변수
| 키 | 기본값 | 설명 |
| --- | --- | --- |
| `PORT` | `7860` | 바인드 포트 |
| `MELO_DEVICE` | `auto` | `cpu` / `cuda` / `mps` / `auto` |
## 문제 해결
- 첫 합성이 30초 이상 걸리면 정상 (모델 로딩 + JIT 워밍업). 두 번째부터 ms 단위.
- `ModuleNotFoundError: melo``pip install -r requirements.txt` 가 실패한 경우. 가상환경 활성화 여부 확인.
- 한국어 발음이 깨질 때 → 입력에 영문/이모지가 섞이면 운율이 어색해질 수 있음. 디스코드 봇 통합 단계에서 `TTSClient.textReplace` 와 같은 사전 치환을 그대로 활용.
- 디스코드에 붙이려면: MeloTTS 컨테이너를 LAN(예: 시그니처 서버 옆)에 띄우고, `src/utils/tts/Chzzk.ts` 와 같은 시그니처의 `textToSpeech(text): Promise<Buffer>` 어댑터(`src/utils/tts/Melo.ts`)를 추가한 뒤 `src/classes/TTSClient.ts` 의 import 만 교체.
## 참고
- MeloTTS: https://github.com/myshell-ai/MeloTTS
- MeloTTS-Korean 모델카드: https://huggingface.co/myshell-ai/MeloTTS-Korean
- 라이선스: MIT

107
melo-test/index.html Normal file
View File

@@ -0,0 +1,107 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>MeloTTS-Korean 테스트</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Apple SD Gothic Neo", sans-serif;
max-width: 760px; margin: 40px auto; padding: 0 20px; line-height: 1.5;
}
h1 { margin-bottom: 4px; }
p.lead { color: #666; margin-top: 0; }
textarea {
width: 100%; min-height: 140px; font-size: 16px; padding: 12px;
box-sizing: border-box; border-radius: 8px; border: 1px solid #ccc;
font-family: inherit;
}
.row { display: flex; gap: 12px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
button {
padding: 10px 22px; font-size: 16px; cursor: pointer;
border-radius: 8px; border: 1px solid #888; background: #f5f5f5;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.meta { color: #666; font-size: 14px; }
.err { color: #c00; }
audio { width: 100%; margin-top: 16px; }
.preset { font-size: 14px; padding: 6px 10px; }
hr { margin: 24px 0; border: none; border-top: 1px solid #ddd; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<h1>MeloTTS-Korean 테스트</h1>
<p class="lead">
한국어 텍스트를 입력 후 <b>합성</b> 버튼을 누르세요.
서버 첫 호출은 모델 로딩이 끝나야 하므로 수십 초 걸릴 수 있습니다.
</p>
<textarea id="text" placeholder="안녕하세요. 메로 한국어 티티에스 테스트입니다."></textarea>
<div class="row">
<button class="preset" data-text="안녕하세요. 메로 한국어 티티에스 테스트입니다.">기본 인사</button>
<button class="preset" data-text="오늘 디스코드 채팅 봇에 새 보이스 엔진을 붙여 봤습니다. 발음이 어떤가요?">중간 길이</button>
<button class="preset" data-text="ㅋㅋㅋ 진짜요? 그건 좀 너무한데. 다시 한 번 확인해 주실 수 있을까요?">구어체</button>
</div>
<div class="row">
<label>speed
<input type="number" id="speed" value="1.0" step="0.1" min="0.5" max="2.0" style="width:70px">
</label>
<button id="go">합성</button>
<span class="meta" id="status">대기중</span>
</div>
<audio id="audio" controls></audio>
<hr>
<p class="meta">
서버 엔드포인트: <code>POST /tts</code> body <code>{ "text": "...", "speed": 1.0 }</code><br>
응답: <code>audio/wav</code> 바이너리. 헤더 <code>X-Synth-Ms</code> 에 합성 소요 시간(ms).
</p>
<script>
const $ = id => document.getElementById(id);
document.querySelectorAll(".preset").forEach(b => {
b.onclick = () => { $("text").value = b.dataset.text; };
});
$("go").onclick = async () => {
const text = $("text").value.trim();
const speed = parseFloat($("speed").value) || 1.0;
if (!text) { $("status").textContent = "텍스트 입력 필요"; return; }
$("go").disabled = true;
$("status").textContent = "합성중...";
const t0 = performance.now();
try {
const res = await fetch("/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, speed })
});
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(`${res.status} ${msg}`);
}
const synthMs = res.headers.get("X-Synth-Ms");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = $("audio");
audio.src = url;
audio.play().catch(() => {});
const totalMs = Math.round(performance.now() - t0);
$("status").innerHTML =
`합성 ${synthMs ?? "?"}ms / 총 ${totalMs}ms / 크기 ${(blob.size/1024).toFixed(1)}KB`;
} catch (e) {
$("status").innerHTML = `<span class="err">에러: ${e.message}</span>`;
} finally {
$("go").disabled = false;
}
};
</script>
</body>
</html>

View File

@@ -0,0 +1,7 @@
# MeloTTS 본체는 PyPI 패키지 이름이 바뀌어 git 직접 설치가 가장 안전.
# (https://github.com/myshell-ai/MeloTTS)
git+https://github.com/myshell-ai/MeloTTS.git@main
fastapi>=0.110
uvicorn[standard]>=0.27
pydantic>=2.0

104
melo-test/server.py Normal file
View File

@@ -0,0 +1,104 @@
"""
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 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}
speaker_id = spk.get("KR", 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
@app.post("/tts")
def tts(req: TTSReq):
model = state.get("model")
if model is None:
raise HTTPException(503, "model not ready")
text = (req.text or "").strip()
if not text:
raise HTTPException(400, "empty text")
if len(text) > 500:
raise HTTPException(400, "too long (<=500)")
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)