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>
224 lines
7.4 KiB
Python
224 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
|
|
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 {
|
|
"default_backend": DEFAULT_TTS_BACKEND,
|
|
"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]]:
|
|
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")
|
|
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
|