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:
du5t
2026-06-18 18:46:45 +09:00
parent ac49bfc7f9
commit acdf098c41
13 changed files with 761 additions and 24 deletions

View File

@@ -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)

View File

@@ -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

View 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)