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:
93
edge-test/README.md
Normal file
93
edge-test/README.md
Normal 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
151
edge-test/index.html
Normal 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>
|
||||
5
edge-test/requirements.txt
Normal file
5
edge-test/requirements.txt
Normal 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
100
edge-test/server.py
Normal 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)
|
||||
Reference in New Issue
Block a user