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:
5
melo-test/.gitignore
vendored
Normal file
5
melo-test/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.wav
|
||||||
|
*.mp3
|
||||||
75
melo-test/README.md
Normal file
75
melo-test/README.md
Normal 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
107
melo-test/index.html
Normal 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>
|
||||||
7
melo-test/requirements.txt
Normal file
7
melo-test/requirements.txt
Normal 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
104
melo-test/server.py
Normal 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)
|
||||||
Reference in New Issue
Block a user