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