From 124d9dc99d659cc533c92657afc5c05628157c5a Mon Sep 17 00:00:00 2001 From: Claude Owner Date: Tue, 26 May 2026 15:16:54 +0900 Subject: [PATCH] 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 --- melo-test/.gitignore | 5 ++ melo-test/README.md | 75 ++++++++++++++++++++++++++ melo-test/index.html | 107 +++++++++++++++++++++++++++++++++++++ melo-test/requirements.txt | 7 +++ melo-test/server.py | 104 +++++++++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 melo-test/.gitignore create mode 100644 melo-test/README.md create mode 100644 melo-test/index.html create mode 100644 melo-test/requirements.txt create mode 100644 melo-test/server.py diff --git a/melo-test/.gitignore b/melo-test/.gitignore new file mode 100644 index 0000000..5fc1c35 --- /dev/null +++ b/melo-test/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +*.wav +*.mp3 diff --git a/melo-test/README.md b/melo-test/README.md new file mode 100644 index 0000000..95f0c3d --- /dev/null +++ b/melo-test/README.md @@ -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` 어댑터(`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 diff --git a/melo-test/index.html b/melo-test/index.html new file mode 100644 index 0000000..d9f528f --- /dev/null +++ b/melo-test/index.html @@ -0,0 +1,107 @@ + + + + + +MeloTTS-Korean 테스트 + + + +

MeloTTS-Korean 테스트

+

+ 한국어 텍스트를 입력 후 합성 버튼을 누르세요. + 서버 첫 호출은 모델 로딩이 끝나야 하므로 수십 초 걸릴 수 있습니다. +

+ + + +
+ + + +
+ +
+ + + 대기중 +
+ + + +
+

+ 서버 엔드포인트: POST /tts body { "text": "...", "speed": 1.0 }
+ 응답: audio/wav 바이너리. 헤더 X-Synth-Ms 에 합성 소요 시간(ms). +

+ + + + diff --git a/melo-test/requirements.txt b/melo-test/requirements.txt new file mode 100644 index 0000000..903af8a --- /dev/null +++ b/melo-test/requirements.txt @@ -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 diff --git a/melo-test/server.py b/melo-test/server.py new file mode 100644 index 0000000..f6a58d0 --- /dev/null +++ b/melo-test/server.py @@ -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)