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

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