Add XTTS-v2 as first TTS backend, extensible for more
Mirrors the ASR backend pattern exactly: a BACKENDS dict in
tts/router.py maps a backend name to its worker URL, so adding the
next backend is just a new worker file + venv + supervisord entry +
one dict line. XTTS-v2 runs in its own venv (--system-site-packages,
inherits base image torch/CUDA) as a new supervisord program on
port 8005.
XTTS is zero-shot voice cloning, so a reference-voice library was
added (/tts/voices CRUD, stored under /srv/tts/voices) — synthesis
requires picking a previously uploaded voice. Results and model
cache live under /srv/tts/{results,models-cache}, new quadlet
volumes, owned by the same 983:983 user as the existing /srv/asr
dirs.
Fixed two environment issues uncovered while getting XTTS to
actually run inside the container (non-root user, root-built venvs):
- coqui-tts only pins transformers>=4.57 with no ceiling, so pip
installed an incompatible 5.x; pinned to the last 4.x release.
- HOME defaults to /app (owned by root) for the container's runtime
user, so numba/matplotlib/torch cache writes failed; HOME is now
forced to /tmp in all three workers (faster_whisper, qwen3, xtts)
before any of those libraries get imported.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,15 @@ from core.config import env_str
|
||||
|
||||
TTS_BASE_DIR = Path(env_str("TTS_BASE_DIR", "/srv/tts"))
|
||||
TTS_MODEL_CACHE = Path(env_str("TTS_MODEL_CACHE_DIR", str(TTS_BASE_DIR / "models-cache")))
|
||||
TTS_UPLOAD_DIR = TTS_BASE_DIR / "uploads"
|
||||
TTS_VOICE_DIR = TTS_BASE_DIR / "voices"
|
||||
TTS_RESULT_DIR = TTS_BASE_DIR / "results"
|
||||
|
||||
DEFAULT_TTS_BACKEND = env_str("DEFAULT_TTS_BACKEND", "")
|
||||
DEFAULT_TTS_BACKEND = env_str("DEFAULT_TTS_BACKEND", "xtts")
|
||||
DEFAULT_TTS_LANGUAGE = env_str("DEFAULT_TTS_LANGUAGE", "ko")
|
||||
|
||||
XTTS_URL = env_str("XTTS_URL", "http://127.0.0.1:8005")
|
||||
|
||||
|
||||
def ensure_runtime_dirs() -> None:
|
||||
for p in [TTS_BASE_DIR, TTS_MODEL_CACHE, TTS_VOICE_DIR, TTS_RESULT_DIR]:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,29 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import httpx
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from tts.config import DEFAULT_TTS_BACKEND
|
||||
from tts.config import (
|
||||
DEFAULT_TTS_BACKEND,
|
||||
DEFAULT_TTS_LANGUAGE,
|
||||
TTS_RESULT_DIR,
|
||||
TTS_VOICE_DIR,
|
||||
XTTS_URL,
|
||||
ensure_runtime_dirs,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BACKENDS = {
|
||||
"xtts": XTTS_URL,
|
||||
}
|
||||
|
||||
# XTTS-v2가 지원하는 언어 (Coqui 공식 문서 기준)
|
||||
XTTS_LANGUAGES = [
|
||||
"ko", "en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru",
|
||||
"nl", "cs", "ar", "zh-cn", "ja", "hu", "hi",
|
||||
]
|
||||
|
||||
_SAFE_NAME = re.compile(r"[^a-zA-Z0-9_\-가-힣]+")
|
||||
|
||||
|
||||
def _sanitize_voice_name(name: str) -> str:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="보이스 이름이 비어 있습니다.")
|
||||
return _SAFE_NAME.sub("_", name)
|
||||
|
||||
|
||||
def _find_voice_path(name: str) -> Optional[Path]:
|
||||
safe = _sanitize_voice_name(name)
|
||||
matches = list(TTS_VOICE_DIR.glob(f"{safe}.*"))
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
# ─── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/config")
|
||||
def config() -> Dict[str, Any]:
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"default_backend": DEFAULT_TTS_BACKEND,
|
||||
"backends": {},
|
||||
"message": "TTS 백엔드가 아직 연동되지 않았습니다.",
|
||||
"default_language": DEFAULT_TTS_LANGUAGE,
|
||||
"backends": {
|
||||
"xtts": {"languages": XTTS_LANGUAGES},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── Voices (레퍼런스 보이스 관리) ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/voices")
|
||||
def list_voices() -> List[Dict[str, str]]:
|
||||
ensure_runtime_dirs()
|
||||
voices = []
|
||||
for p in sorted(TTS_VOICE_DIR.iterdir()):
|
||||
if p.is_file():
|
||||
voices.append({"name": p.stem, "filename": p.name})
|
||||
return voices
|
||||
|
||||
|
||||
@router.post("/voices")
|
||||
async def upload_voice(file: UploadFile = File(...), name: str = Form(...)) -> Dict[str, str]:
|
||||
ensure_runtime_dirs()
|
||||
safe_name = _sanitize_voice_name(name)
|
||||
suffix = Path(file.filename or "voice.wav").suffix or ".wav"
|
||||
|
||||
# 같은 이름의 기존 파일(다른 확장자 포함) 제거 후 새로 저장
|
||||
for old in TTS_VOICE_DIR.glob(f"{safe_name}.*"):
|
||||
old.unlink(missing_ok=True)
|
||||
|
||||
dest = TTS_VOICE_DIR / f"{safe_name}{suffix}"
|
||||
dest.write_bytes(await file.read())
|
||||
return {"name": safe_name, "filename": dest.name}
|
||||
|
||||
|
||||
@router.delete("/voices/{name}")
|
||||
def delete_voice(name: str) -> Dict[str, str]:
|
||||
path = _find_voice_path(name)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="보이스를 찾을 수 없습니다.")
|
||||
path.unlink(missing_ok=True)
|
||||
return {"status": "deleted", "name": name}
|
||||
|
||||
|
||||
@router.get("/voices/{name}/audio")
|
||||
def serve_voice_audio(name: str) -> FileResponse:
|
||||
path = _find_voice_path(name)
|
||||
if not path:
|
||||
raise HTTPException(status_code=404, detail="보이스를 찾을 수 없습니다.")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
# ─── History ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/history")
|
||||
def list_history() -> List[Dict[str, Any]]:
|
||||
return []
|
||||
ensure_runtime_dirs()
|
||||
records = []
|
||||
for p in sorted(TTS_RESULT_DIR.glob("*.json"), reverse=True):
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
records.append({
|
||||
"id": p.stem,
|
||||
"backend": data.get("backend", ""),
|
||||
"language": data.get("language", ""),
|
||||
"voice": data.get("voice", ""),
|
||||
"text_preview": (data.get("text", "") or "")[:120],
|
||||
"duration": data.get("duration"),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return records
|
||||
|
||||
|
||||
@router.get("/history/{record_id}")
|
||||
def get_history(record_id: str) -> Dict[str, Any]:
|
||||
safe_id = Path(record_id).name
|
||||
p = TTS_RESULT_DIR / f"{safe_id}.json"
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="Record not found")
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@router.delete("/history/{record_id}")
|
||||
def delete_history(record_id: str) -> Dict[str, str]:
|
||||
safe_id = Path(record_id).name
|
||||
p = TTS_RESULT_DIR / f"{safe_id}.json"
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="Record not found")
|
||||
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
audio_path = data.get("audio_file", "")
|
||||
if audio_path:
|
||||
ap = Path(audio_path)
|
||||
if ap.exists() and ap.is_relative_to(TTS_RESULT_DIR):
|
||||
ap.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
p.unlink(missing_ok=True)
|
||||
return {"status": "deleted", "id": safe_id}
|
||||
|
||||
|
||||
@router.get("/audio/{filename}")
|
||||
def serve_audio(filename: str) -> FileResponse:
|
||||
safe_name = Path(filename).name
|
||||
p = TTS_RESULT_DIR / safe_name
|
||||
if not p.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return FileResponse(p)
|
||||
|
||||
|
||||
# ─── Synthesize ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/synthesize")
|
||||
def synthesize() -> None:
|
||||
raise HTTPException(status_code=501, detail="TTS 합성 기능은 아직 구현되지 않았습니다.")
|
||||
async def synthesize(
|
||||
text: str = Form(...),
|
||||
backend: str = Form(DEFAULT_TTS_BACKEND),
|
||||
language: str = Form(DEFAULT_TTS_LANGUAGE),
|
||||
voice: str = Form(...),
|
||||
) -> Dict[str, Any]:
|
||||
if backend not in BACKENDS:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}")
|
||||
if not text.strip():
|
||||
raise HTTPException(status_code=400, detail="text가 비어 있습니다.")
|
||||
|
||||
voice_path = _find_voice_path(voice)
|
||||
if not voice_path:
|
||||
raise HTTPException(status_code=404, detail=f"보이스 '{voice}'를 찾을 수 없습니다. 먼저 보이스를 업로드하세요.")
|
||||
|
||||
ensure_runtime_dirs()
|
||||
worker_url = BACKENDS[backend]
|
||||
|
||||
if backend == "xtts":
|
||||
data = {
|
||||
"text": text,
|
||||
"language": language,
|
||||
"speaker_wav": str(voice_path),
|
||||
}
|
||||
else:
|
||||
data = {"text": text, "language": language}
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=60.0)) as client:
|
||||
response = await client.post(f"{worker_url}/synthesize", data=data)
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise HTTPException(status_code=response.status_code, detail=f"Backend error: {response.text}")
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
audio_path = TTS_RESULT_DIR / f"{timestamp}.wav"
|
||||
audio_path.write_bytes(response.content)
|
||||
|
||||
duration = response.headers.get("x-audio-duration")
|
||||
payload = {
|
||||
"backend": backend,
|
||||
"language": language,
|
||||
"voice": voice,
|
||||
"text": text,
|
||||
"duration": float(duration) if duration else None,
|
||||
"audio_file": str(audio_path),
|
||||
"_timestamp": timestamp,
|
||||
}
|
||||
result_path = TTS_RESULT_DIR / f"{timestamp}.json"
|
||||
result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
payload["id"] = timestamp
|
||||
payload["audio_url"] = f"/tts/audio/{audio_path.name}"
|
||||
return payload
|
||||
|
||||
117
app/tts/workers/xtts_worker.py
Normal file
117
app/tts/workers/xtts_worker.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, "/app")
|
||||
from tts.config import TTS_MODEL_CACHE, ensure_runtime_dirs
|
||||
|
||||
# TTS.api를 import하기 전에 설정해야 적용된다.
|
||||
os.environ.setdefault("TTS_HOME", str(TTS_MODEL_CACHE))
|
||||
os.environ.setdefault("COQUI_TOS_AGREED", "1") # XTTS(CPML 라이선스) 동의 프롬프트를 비대화식으로 통과
|
||||
# 컨테이너는 983 유저로 실행되는데 HOME(/app)이 root 소유라 numba/matplotlib/torch가
|
||||
# 각자 ~/.cache, ~/.config 아래에 쓰려다 실패한다. HOME을 쓰기 가능한 곳으로 돌린다.
|
||||
os.environ["HOME"] = "/tmp" # 컨테이너 기본 HOME=/app은 983 유저가 쓰기 불가 (setdefault로는 덮어쓰기 안 됨)
|
||||
os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
|
||||
|
||||
import soundfile as sf
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Form, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
app = FastAPI(title="TTS XTTS Worker")
|
||||
|
||||
MODEL_NAME = "tts_models/multilingual/multi-dataset/xtts_v2"
|
||||
_MODEL_CACHE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[xtts] {msg}", flush=True)
|
||||
|
||||
|
||||
def _device() -> str:
|
||||
try:
|
||||
import torch
|
||||
return "cuda" if torch.cuda.is_available() else "cpu"
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _load_model() -> Any:
|
||||
if MODEL_NAME in _MODEL_CACHE:
|
||||
return _MODEL_CACHE[MODEL_NAME]
|
||||
|
||||
from TTS.api import TTS
|
||||
|
||||
_log(f"loading model {MODEL_NAME}")
|
||||
tts = TTS(MODEL_NAME).to(_device())
|
||||
_MODEL_CACHE[MODEL_NAME] = tts
|
||||
_log("model loaded")
|
||||
return tts
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
ensure_runtime_dirs()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {"status": "ok", "device": _device(), "loaded": MODEL_NAME in _MODEL_CACHE}
|
||||
|
||||
|
||||
@app.post("/synthesize")
|
||||
def synthesize(
|
||||
text: str = Form(...),
|
||||
language: str = Form("ko"),
|
||||
speaker_wav: str = Form(...),
|
||||
) -> Response:
|
||||
speaker_path = Path(speaker_wav)
|
||||
if not speaker_path.exists():
|
||||
raise HTTPException(status_code=400, detail=f"speaker_wav not found: {speaker_wav}")
|
||||
|
||||
try:
|
||||
tts = _load_model()
|
||||
except Exception as e:
|
||||
_log(f"model load error: {type(e).__name__}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"모델 로드 실패: {type(e).__name__}: {e}")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
out_path = Path(tmp.name)
|
||||
|
||||
try:
|
||||
_log(f"synthesizing language={language} chars={len(text)}")
|
||||
tts.tts_to_file(
|
||||
text=text,
|
||||
speaker_wav=str(speaker_path),
|
||||
language=language,
|
||||
file_path=str(out_path),
|
||||
)
|
||||
audio_bytes = out_path.read_bytes()
|
||||
duration = sf.info(str(out_path)).duration
|
||||
return Response(
|
||||
content=audio_bytes,
|
||||
media_type="audio/wav",
|
||||
headers={"x-audio-duration": str(round(duration, 3))},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_log(f"synthesize error: {type(e).__name__}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
|
||||
finally:
|
||||
out_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", type=int, default=8005)
|
||||
args = parser.parse_args()
|
||||
ensure_runtime_dirs()
|
||||
_log(f"starting host={args.host} port={args.port} device={_device()}")
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
Reference in New Issue
Block a user