test(edge): add Microsoft Edge TTS Korean test harness

Pure-Python alternative to melo-test/ when the MeloTTS native build
chain (MeCab/Rust/fugashi/mecab-python3/tokenizers) refuses to install.

- edge-test/server.py: FastAPI + edge_tts.Communicate.stream() -> mp3,
  9 Korean Neural voices, rate/pitch knobs.
- edge-test/index.html: voice selector + rate/pitch + presets + audio.
- requirements.txt: edge-tts + fastapi + uvicorn. No native deps.
- README: comparison table vs MeloTTS, setup, caveat that edge-tts is
  an unofficial wrapper over the Edge "Read Aloud" endpoint.

Default port 7861 so it coexists with melo-test/ (7860).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Claude Owner
2026-05-26 16:07:27 +09:00
parent c68de5a232
commit d3aeab937b
4 changed files with 349 additions and 0 deletions

93
edge-test/README.md Normal file
View File

@@ -0,0 +1,93 @@
# Microsoft Edge TTS — 한국어 테스트 환경
MeloTTS 의 빌드 의존성(MeCab, Rust, fugashi, mecab-python3, tokenizers)이
계속 깨질 때 사용할 수 있는 **즉시 실행 가능한 대안**.
내부적으로 Microsoft Edge 브라우저의 "소리내어 읽기" 가 쓰는 Azure Neural Voice
엔드포인트를 호출합니다(공식 SDK 가 아닌 커뮤니티 라이브러리 `edge-tts`).
## 왜 이걸 먼저 테스트해보는 게 좋은가
| 항목 | MeloTTS-Korean | Edge TTS |
| --- | --- | --- |
| 설치 시간 | 5~15분 + 모델 가중치 수백 MB | 30초, 의존성 4개 |
| 네이티브 빌드 | MeCab + Rust + PyTorch | 없음 |
| Python 버전 | 3.10/3.11 강제 | 3.9+ 어디서나 |
| 자연스러움(한국어) | 좋음 | Azure Neural 그대로, 매우 자연 |
| 비용 | 무료(MIT) | 무료(비공식 Edge 엔드포인트) |
| 지연 | 첫 호출 후 ms 단위 | 200~500ms 정도(네트워크) |
| 보이스 수 | 1 (KR) | 9 (남/여/시니어/다국어) |
| 안정성 위험 | 자가호스팅 = 본인 책임 | MS 가 정책 바꾸면 끊길 수 있음 |
테스트 우선순위로는 **Edge TTS 를 먼저 들어보고**, 마음에 들면 그대로 채택,
"오프라인/내부망/완전 자체 호스팅" 요구가 있으면 MeloTTS Docker 로 진행하는 흐름을 권합니다.
## 설치 & 실행
```bash
# 1. 가상환경
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 2. 의존성 (30초)
pip install --upgrade pip
pip install -r requirements.txt
# 3. 서버
python server.py
# 포트 변경: PORT=8001 python server.py (기본 7861)
```
브라우저: `http://localhost:7861`
> MeloTTS 테스트(`melo-test/`, 포트 7860)와 동시에 띄울 수 있도록 기본 포트가 다릅니다.
## API
```http
POST /tts
Content-Type: application/json
{
"text": ".",
"voice": "ko-KR-SunHiNeural",
"rate": "+0%",
"pitch": "+0Hz"
}
```
응답: `audio/mpeg` (MP3). 헤더 `X-Synth-Ms` 에 합성 ms.
```http
GET /voices
```
응답: 한국어 보이스 목록(JSON).
## 사용 가능한 한국어 보이스
- `ko-KR-SunHiNeural` — 여, 차분 (디폴트)
- `ko-KR-InJoonNeural` — 남, 표준
- `ko-KR-BongJinNeural` — 남, 중후
- `ko-KR-GookMinNeural` — 남, 친근
- `ko-KR-HyunsuMultilingualNeural` — 남, 다국어
- `ko-KR-JiMinNeural` — 여, 밝음
- `ko-KR-SeoHyeonNeural` — 여, 차분
- `ko-KR-SoonBokNeural` — 여, 시니어 여성
- `ko-KR-YuJinNeural` — 여, 표준
## 주의
- `edge-tts` 는 비공식 라이브러리입니다. Microsoft 가 엔드포인트를 바꾸면 끊깁니다(과거 수년간은 안정적이었음).
- 운영 안정성/SLA 가 필요하면 Azure Cognitive Services(유료)로 같은 보이스를 정식 사용할 수 있습니다 — 코드 거의 동일.
- 봇에 붙일 때 디스코드 메시지 전체를 다 보내면 텍스트가 외부 서비스에 흘러갑니다. 사내·민감 채널엔 부적합.
## 디스코드 봇 연동 (테스트 성공 후)
`src/utils/tts/Edge.ts` 를 추가하고 `src/classes/TTSClient.ts` 의 import 교체.
`textToSpeech(text): Promise<Buffer>` 시그니처는 `Chzzk.ts` / `Google.ts` 와 동일하게.
## 참고
- edge-tts (Python): https://github.com/rany2/edge-tts
- Azure Neural Voices 목록: https://learn.microsoft.com/azure/ai-services/speech-service/language-support

151
edge-test/index.html Normal file
View File

@@ -0,0 +1,151 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Edge TTS 한국어 테스트</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; }
select, input[type=number] {
padding: 6px 8px; font-size: 15px; border-radius: 6px; border: 1px solid #888;
}
button {
padding: 10px 22px; font-size: 16px; cursor: pointer;
border-radius: 8px; border: 1px solid #888; background: #f5f5f5;
}
button.preset { padding: 6px 10px; font-size: 14px; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.meta { color: #666; font-size: 14px; }
.err { color: #c00; }
audio { width: 100%; margin-top: 16px; }
hr { margin: 24px 0; border: none; border-top: 1px solid #ddd; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
label { font-size: 14px; }
</style>
</head>
<body>
<h1>Microsoft Edge TTS — 한국어 테스트</h1>
<p class="lead">
Microsoft Azure Neural Voice 를 무료로(브라우저 Read Aloud 동일 엔드포인트) 사용.
네이티브 빌드 의존성 0, 설치 30초.
</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>
<button class="preset" data-text="2026년 5월 26일 화요일, 서울 기온 23도, 습도 60퍼센트입니다.">숫자/날짜</button>
</div>
<div class="row">
<label>보이스
<select id="voice"></select>
</label>
<label>속도
<select id="rate">
<option value="-30%">느림 -30%</option>
<option value="-10%">조금 느림 -10%</option>
<option value="+0%" selected>표준</option>
<option value="+10%">조금 빠름 +10%</option>
<option value="+30%">빠름 +30%</option>
</select>
</label>
<label>피치
<select id="pitch">
<option value="-50Hz">낮음</option>
<option value="+0Hz" selected>표준</option>
<option value="+50Hz">높음</option>
</select>
</label>
</div>
<div class="row">
<button id="go">합성</button>
<span class="meta" id="status">대기중</span>
</div>
<audio id="audio" controls></audio>
<hr>
<p class="meta">
엔드포인트: <code>POST /tts</code>, 응답 <code>audio/mpeg</code>, 헤더 <code>X-Synth-Ms</code>=합성 ms.
보이스 목록: <code>GET /voices</code>.
</p>
<script>
const $ = id => document.getElementById(id);
document.querySelectorAll(".preset").forEach(b => {
b.onclick = () => { $("text").value = b.dataset.text; };
});
async function loadVoices() {
try {
const res = await fetch("/voices");
const json = await res.json();
const sel = $("voice");
sel.innerHTML = "";
for (const v of json.voices) {
const opt = document.createElement("option");
opt.value = v.name;
opt.textContent = `${v.name} (${v.label})`;
sel.appendChild(opt);
}
} catch (e) {
$("status").innerHTML = `<span class="err">보이스 목록 로딩 실패: ${e.message}</span>`;
}
}
loadVoices();
$("go").onclick = async () => {
const text = $("text").value.trim();
const voice = $("voice").value;
const rate = $("rate").value;
const pitch = $("pitch").value;
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, voice, rate, pitch })
});
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,5 @@
# 순수 Python 패키지만. 네이티브 빌드 의존성 없음.
edge-tts>=6.1.0
fastapi>=0.110
uvicorn[standard]>=0.27
pydantic>=2.0

100
edge-test/server.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Microsoft Edge TTS 한국어 테스트 서버.
요청한 텍스트를 Microsoft Edge 의 Read Aloud(Neural) 서비스로 합성.
네이티브 빌드 의존성 0 — PyTorch / MeCab / Rust / GPU 모두 불필요.
순수 Python 패키지(edge-tts + fastapi + uvicorn) 만으로 동작.
POST /tts { "text": "...", "voice": "ko-KR-SunHiNeural", "rate": "+0%", "pitch": "+0Hz" } -> audio/mpeg
GET /voices -> 사용 가능한 한국어 보이스 목록
GET / -> index.html
"""
import os
import time
import logging
from io import BytesIO
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
import edge_tts
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("edge-test")
# Microsoft Azure Neural Voices (Korean). 실제 가용 목록은 /voices 가 동적으로 반환.
KOREAN_VOICES = [
("ko-KR-SunHiNeural", "여 · 차분"),
("ko-KR-InJoonNeural", "남 · 표준"),
("ko-KR-BongJinNeural", "남 · 중후"),
("ko-KR-GookMinNeural", "남 · 친근"),
("ko-KR-HyunsuMultilingualNeural", "남 · 다국어"),
("ko-KR-JiMinNeural", "여 · 밝음"),
("ko-KR-SeoHyeonNeural", "여 · 차분"),
("ko-KR-SoonBokNeural", "여 · 시니어"),
("ko-KR-YuJinNeural", "여 · 표준"),
]
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class TTSReq(BaseModel):
text: str
voice: str = "ko-KR-SunHiNeural"
rate: str = "+0%" # 예: "-10%", "+20%"
pitch: str = "+0Hz" # 예: "-50Hz", "+30Hz"
@app.get("/voices")
def voices():
return {"voices": [{"name": n, "label": lbl} for n, lbl in KOREAN_VOICES]}
@app.post("/tts")
async def tts(req: TTSReq):
text = (req.text or "").strip()
if not text:
raise HTTPException(400, "empty text")
if len(text) > 1000:
raise HTTPException(400, "too long (<=1000)")
voice = req.voice or "ko-KR-SunHiNeural"
t0 = time.time()
buf = BytesIO()
try:
communicate = edge_tts.Communicate(text, voice, rate=req.rate, pitch=req.pitch)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
buf.write(chunk["data"])
except Exception as e:
log.error("edge_tts error: %r", e)
raise HTTPException(502, f"edge_tts failed: {e}")
dt_ms = int((time.time() - t0) * 1000)
data = buf.getvalue()
if not data:
raise HTTPException(502, "empty audio from edge_tts")
log.info("tts ok: %dms, %dB, voice=%s, text=%r", dt_ms, len(data), voice, text[:30])
return Response(
content=data,
media_type="audio/mpeg",
headers={"X-Synth-Ms": str(dt_ms), "Cache-Control": "no-store"},
)
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", "7861"))
uvicorn.run(app, host="0.0.0.0", port=port)