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