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

@@ -33,6 +33,11 @@ RUN python -m venv --system-site-packages /opt/venvs/qwen3 && \
/opt/venvs/qwen3/bin/pip install --upgrade pip && \ /opt/venvs/qwen3/bin/pip install --upgrade pip && \
/opt/venvs/qwen3/bin/pip install -r /build/envs/qwen3.txt /opt/venvs/qwen3/bin/pip install -r /build/envs/qwen3.txt
# xtts venv — base image의 torch/CUDA 상속
RUN python -m venv --system-site-packages /opt/venvs/xtts && \
/opt/venvs/xtts/bin/pip install --upgrade pip && \
/opt/venvs/xtts/bin/pip install -r /build/envs/xtts.txt
COPY app/ /app/ COPY app/ /app/
RUN chmod +x /app/start.sh RUN chmod +x /app/start.sh

View File

@@ -22,6 +22,12 @@ from asr.config import (
resolve_custom_model_path, resolve_custom_model_path,
) )
import os
# 컨테이너는 983 유저로 실행되는데 HOME(/app)이 root 소유라, pyannote.audio가 쓰는
# numba/matplotlib/torch가 각자 ~/.cache, ~/.config 아래에 쓰려다 실패한다.
os.environ["HOME"] = "/tmp" # 컨테이너 기본 HOME=/app은 983 유저가 쓰기 불가 (setdefault로는 덮어쓰기 안 됨)
os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
app = FastAPI(title="ASR Faster-Whisper Worker") app = FastAPI(title="ASR Faster-Whisper Worker")
_MODEL_CACHE: Dict[str, WhisperModel] = {} _MODEL_CACHE: Dict[str, WhisperModel] = {}
_DIARIZATION_PIPELINE: Any = None _DIARIZATION_PIPELINE: Any = None

View File

@@ -14,6 +14,12 @@ import sys
sys.path.insert(0, "/app") sys.path.insert(0, "/app")
from asr.config import DEVICE, MODEL_CACHE, ensure_runtime_dirs from asr.config import DEVICE, MODEL_CACHE, ensure_runtime_dirs
import os
# 컨테이너는 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")
app = FastAPI(title="ASR Qwen3 Worker") app = FastAPI(title="ASR Qwen3 Worker")
_PIPE_CACHE: Dict[str, Any] = {} _PIPE_CACHE: Dict[str, Any] = {}

View File

@@ -9,11 +9,12 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from asr.config import ensure_runtime_dirs from asr.config import ensure_runtime_dirs as ensure_asr_dirs
from asr.router import router as asr_router from asr.router import router as asr_router
from asr.router import ws_router as asr_ws_router from asr.router import ws_router as asr_ws_router
from core.auth import SESSION_SECRET_KEY, require_login from core.auth import SESSION_SECRET_KEY, require_login
from core.auth import router as auth_router from core.auth import router as auth_router
from tts.config import ensure_runtime_dirs as ensure_tts_dirs
from tts.router import router as tts_router from tts.router import router as tts_router
app = FastAPI(title="Speech Gateway", docs_url="/docs", redoc_url="/redoc") app = FastAPI(title="Speech Gateway", docs_url="/docs", redoc_url="/redoc")
@@ -42,7 +43,8 @@ app.include_router(tts_router, prefix="/tts", tags=["tts"], dependencies=[Depend
@app.on_event("startup") @app.on_event("startup")
def startup() -> None: def startup() -> None:
ensure_runtime_dirs() ensure_asr_dirs()
ensure_tts_dirs()
if UI_DIR.exists(): if UI_DIR.exists():
app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui") app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
app.mount("/assets", StaticFiles(directory=str(UI_DIR / "assets")), name="assets") app.mount("/assets", StaticFiles(directory=str(UI_DIR / "assets")), name="assets")

View File

@@ -25,6 +25,17 @@ stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
priority=20 priority=20
[program:xtts]
command=/opt/venvs/xtts/bin/python /app/tts/workers/xtts_worker.py --host 0.0.0.0 --port 8005
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=15
[program:gateway] [program:gateway]
command=/opt/venvs/gateway/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000 command=/opt/venvs/gateway/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000
directory=/app directory=/app

View File

@@ -6,7 +6,15 @@ from core.config import env_str
TTS_BASE_DIR = Path(env_str("TTS_BASE_DIR", "/srv/tts")) 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_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" 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 __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() 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") @router.get("/config")
def config() -> Dict[str, Any]: def config() -> Dict[str, Any]:
return { return {
"status": "not_implemented",
"default_backend": DEFAULT_TTS_BACKEND, "default_backend": DEFAULT_TTS_BACKEND,
"backends": {}, "default_language": DEFAULT_TTS_LANGUAGE,
"message": "TTS 백엔드가 아직 연동되지 않았습니다.", "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") @router.get("/history")
def list_history() -> List[Dict[str, Any]]: 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") @router.post("/synthesize")
def synthesize() -> None: async def synthesize(
raise HTTPException(status_code=501, detail="TTS 합성 기능은 아직 구현되지 않았습니다.") 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)

View File

@@ -296,6 +296,20 @@ pre {
background: #0f1530; background: #0f1530;
} }
/* Voice list (TTS) */
.voice-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
margin-bottom: 8px;
background: #0f1530;
border: 1px solid #3a446d;
border-radius: 10px;
}
.voice-row .voice-name { font-weight: 600; flex-shrink: 0; min-width: 120px; }
.voice-row audio { flex: 1; height: 32px; }
@media (max-width: 720px) { @media (max-width: 720px) {
.grid2, .grid3 { grid-template-columns: 1fr; } .grid2, .grid3 { grid-template-columns: 1fr; }
.mic-row { flex-direction: column; } .mic-row { flex-direction: column; }

View File

@@ -1,15 +1,293 @@
// TTS tab is a structural placeholder until a synthesis backend is wired in. // ─── Config ────────────────────────────────────────────────────────────────
// This just confirms the /tts router is reachable; the UI itself is a static
// "coming soon" card (see index.html). let TTS_CONFIG = null;
async function loadTtsConfig() { async function loadTtsConfig() {
try { try {
const r = await fetch('/tts/config'); const r = await fetch('/tts/config');
const cfg = await r.json(); TTS_CONFIG = await r.json();
console.log('[tts] config', cfg);
} catch (e) { } catch (e) {
console.warn('[tts] config fetch failed', e); TTS_CONFIG = {
default_backend: 'xtts',
default_language: 'ko',
backends: { xtts: { languages: ['ko', 'en'] } },
};
}
populateTtsBackends();
}
const ttsBackendSel = document.getElementById('tts-backend');
const ttsLanguageSel = document.getElementById('tts-language');
const ttsVoiceSel = document.getElementById('tts-voice-select');
function populateTtsBackends() {
if (!TTS_CONFIG) return;
ttsBackendSel.innerHTML = '';
for (const name of Object.keys(TTS_CONFIG.backends)) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
if (name === TTS_CONFIG.default_backend) opt.selected = true;
ttsBackendSel.appendChild(opt);
}
if (!ttsBackendSel.value && ttsBackendSel.options.length) {
ttsBackendSel.selectedIndex = 0;
}
populateTtsLanguages();
}
function populateTtsLanguages() {
if (!TTS_CONFIG) return;
const backend = TTS_CONFIG.backends[ttsBackendSel.value];
const languages = backend?.languages || [];
ttsLanguageSel.innerHTML = '';
for (const lang of languages) {
const opt = document.createElement('option');
opt.value = lang;
opt.textContent = lang;
if (lang === TTS_CONFIG.default_language) opt.selected = true;
ttsLanguageSel.appendChild(opt);
}
if (!ttsLanguageSel.value && languages.length) ttsLanguageSel.value = languages[0];
}
ttsBackendSel.addEventListener('change', populateTtsLanguages);
// ─── Voices ────────────────────────────────────────────────────────────────
const ttsVoiceList = document.getElementById('tts-voice-list');
async function loadTtsVoices() {
let voices = [];
try {
const r = await fetch('/tts/voices');
voices = await r.json();
} catch (e) {
ttsVoiceList.innerHTML = `<div class="muted small">불러오기 실패: ${esc(e.message)}</div>`;
return;
}
renderTtsVoiceList(voices);
renderTtsVoiceSelect(voices);
}
function renderTtsVoiceList(voices) {
if (!voices.length) {
ttsVoiceList.innerHTML = '<div class="muted small">등록된 보이스가 없습니다. 먼저 참조 음성을 업로드하세요.</div>';
return;
}
ttsVoiceList.innerHTML = '';
for (const v of voices) {
const row = document.createElement('div');
row.className = 'voice-row';
row.innerHTML = `
<span class="voice-name">${esc(v.name)}</span>
<audio controls src="/tts/voices/${encodeURIComponent(v.name)}/audio"></audio>
<button type="button" class="btn-danger voice-delete-btn" data-name="${esc(v.name)}">삭제</button>
`;
ttsVoiceList.appendChild(row);
}
ttsVoiceList.querySelectorAll('.voice-delete-btn').forEach(btn => {
btn.addEventListener('click', () => deleteTtsVoice(btn.dataset.name));
});
}
function renderTtsVoiceSelect(voices) {
const prev = ttsVoiceSel.value;
ttsVoiceSel.innerHTML = '';
for (const v of voices) {
const opt = document.createElement('option');
opt.value = v.name;
opt.textContent = v.name;
ttsVoiceSel.appendChild(opt);
}
if (voices.some(v => v.name === prev)) ttsVoiceSel.value = prev;
}
async function deleteTtsVoice(name) {
if (!confirm(`보이스 '${name}'을 삭제하시겠습니까?`)) return;
try {
const r = await fetch(`/tts/voices/${encodeURIComponent(name)}`, { method: 'DELETE' });
if (!r.ok) throw new Error(await r.text());
await loadTtsVoices();
} catch (e) {
alert(`삭제 실패: ${e.message}`);
} }
} }
document.getElementById('tts-voice-upload-btn').addEventListener('click', async () => {
const nameInput = document.getElementById('tts-voice-name');
const fileInput = document.getElementById('tts-voice-file');
const name = nameInput.value.trim();
if (!name) { alert('보이스 이름을 입력하세요.'); return; }
if (!fileInput.files.length) { alert('참조 음성 파일을 선택하세요.'); return; }
const fd = new FormData();
fd.set('name', name);
fd.set('file', fileInput.files[0]);
try {
const r = await fetch('/tts/voices', { method: 'POST', body: fd });
if (!r.ok) throw new Error(await r.text());
nameInput.value = '';
fileInput.value = '';
await loadTtsVoices();
} catch (e) {
alert(`업로드 실패: ${e.message}`);
}
});
// ─── Synthesize ──────────────────────────────────────────────────────────────
const ttsStatus = document.getElementById('tts-status');
const ttsResultSection = document.getElementById('tts-result-section');
const ttsResultAudio = document.getElementById('tts-result-audio');
let ttsLastAudioUrl = null;
document.getElementById('tts-form').addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('tts-text').value.trim();
if (!text) { ttsStatus.textContent = '텍스트를 입력하세요.'; return; }
if (!ttsVoiceSel.value) { ttsStatus.textContent = '보이스를 먼저 등록하세요.'; return; }
const fd = new FormData();
fd.set('text', text);
fd.set('backend', ttsBackendSel.value);
fd.set('language', ttsLanguageSel.value);
fd.set('voice', ttsVoiceSel.value);
ttsStatus.textContent = '합성 요청 중... (첫 호출은 모델 로딩 때문에 오래 걸릴 수 있습니다)';
document.getElementById('tts-submit-btn').disabled = true;
ttsResultSection.style.display = 'none';
try {
const resp = await fetch('/tts/synthesize', { method: 'POST', body: fd });
const payload = await resp.json();
if (!resp.ok) throw new Error(payload.detail || JSON.stringify(payload));
ttsLastAudioUrl = payload.audio_url;
ttsResultAudio.src = payload.audio_url;
ttsResultSection.style.display = '';
const dur = payload.duration ? ` ${payload.duration.toFixed(1)}s` : '';
ttsStatus.textContent = `완료${dur}`;
} catch (err) {
ttsStatus.textContent = `실패: ${err.message}`;
} finally {
document.getElementById('tts-submit-btn').disabled = false;
}
});
document.getElementById('tts-dl-btn').addEventListener('click', () => {
if (!ttsLastAudioUrl) return;
const a = document.createElement('a');
a.href = ttsLastAudioUrl;
a.download = 'speech.wav';
document.body.appendChild(a); a.click(); a.remove();
});
// ─── History ─────────────────────────────────────────────────────────────────
const ttsHistoryList = document.getElementById('tts-history-list');
const ttsHistoryDetail = document.getElementById('tts-history-detail');
let ttsCurrentRecord = null;
async function loadTtsHistory() {
ttsHistoryList.innerHTML = '<div class="muted" style="padding:16px">불러오는 중...</div>';
ttsHistoryDetail.style.display = 'none';
ttsHistoryList.style.display = '';
try {
const r = await fetch('/tts/history');
const records = await r.json();
renderTtsHistoryList(records);
} catch (e) {
ttsHistoryList.innerHTML = `<div class="muted" style="padding:16px">오류: ${esc(e.message)}</div>`;
}
}
function renderTtsHistoryList(records) {
if (!records.length) {
ttsHistoryList.innerHTML = '<div class="muted" style="padding:16px">처리 내역이 없습니다.</div>';
return;
}
let html = '<div class="history-table-wrap"><table class="history-table"><thead><tr>'
+ '<th>시각</th><th>백엔드</th><th>언어</th><th>보이스</th><th>길이</th><th>미리보기</th><th></th>'
+ '</tr></thead><tbody>';
for (const rec of records) {
const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-';
html += `<tr class="history-row" data-id="${esc(rec.id)}">
<td class="mono">${esc(fmtTimestamp(rec.id))}</td>
<td><span class="badge-backend">${esc(rec.backend || '-')}</span></td>
<td>${esc(rec.language || '-')}</td>
<td>${esc(rec.voice || '-')}</td>
<td>${dur}</td>
<td class="preview-cell">${esc(rec.text_preview || '')}</td>
<td><button class="btn-view" data-id="${esc(rec.id)}">보기</button></td>
</tr>`;
}
html += '</tbody></table></div>';
ttsHistoryList.innerHTML = html;
ttsHistoryList.querySelectorAll('.btn-view').forEach(btn => {
btn.addEventListener('click', () => openTtsHistoryDetail(btn.dataset.id));
});
}
async function openTtsHistoryDetail(id) {
try {
const r = await fetch(`/tts/history/${id}`);
if (!r.ok) throw new Error(await r.text());
ttsCurrentRecord = await r.json();
ttsCurrentRecord._id = id;
renderTtsDetail(ttsCurrentRecord);
ttsHistoryList.style.display = 'none';
ttsHistoryDetail.style.display = '';
} catch (e) {
alert(`불러오기 실패: ${e.message}`);
}
}
function renderTtsDetail(data) {
document.getElementById('tts-detail-title').textContent = fmtTimestamp(data._id || '');
const meta = document.getElementById('tts-detail-meta');
const fields = [
['백엔드', data.backend || '-'],
['언어', data.language || '-'],
['보이스', data.voice || '-'],
['길이', data.duration != null ? `${data.duration.toFixed(1)}s` : '-'],
['처리시각', fmtTimestamp(data._timestamp || data._id || '')],
];
meta.innerHTML = fields.map(([k, v]) =>
`<div class="meta-item"><span class="meta-key">${esc(k)}</span><span class="meta-val">${esc(v)}</span></div>`
).join('');
const audioName = (data.audio_file || '').split('/').pop();
document.getElementById('tts-detail-audio').src = audioName ? `/tts/audio/${encodeURIComponent(audioName)}` : '';
document.getElementById('tts-detail-text').value = data.text || '';
}
document.getElementById('tts-history-back-btn').addEventListener('click', () => {
ttsHistoryDetail.style.display = 'none';
ttsHistoryList.style.display = '';
});
document.getElementById('tts-history-refresh-btn').addEventListener('click', loadTtsHistory);
document.getElementById('tts-detail-delete-btn').addEventListener('click', async () => {
if (!ttsCurrentRecord || !ttsCurrentRecord._id) return;
if (!confirm('이 내역을 삭제하시겠습니까? 생성된 오디오도 함께 삭제됩니다.')) return;
try {
const r = await fetch(`/tts/history/${ttsCurrentRecord._id}`, { method: 'DELETE' });
if (!r.ok) throw new Error(await r.text());
ttsHistoryDetail.style.display = 'none';
ttsHistoryList.style.display = '';
await loadTtsHistory();
} catch (e) {
alert(`삭제 실패: ${e.message}`);
}
});
// Refresh history whenever its tab is opened
document.querySelector('[data-target="tab-tts-history"]').addEventListener('click', loadTtsHistory);
// ─── Init ─────────────────────────────────────────────────────────────────────
loadTtsConfig(); loadTtsConfig();
loadTtsVoices();

View File

@@ -320,17 +320,97 @@
<div id="tab-tts-synth" class="tab-panel"> <div id="tab-tts-synth" class="tab-panel">
<section class="card"> <section class="card">
<h2>TTS 기능 준비 중</h2> <h2>보이스</h2>
<p class="muted">음성 합성 백엔드는 아직 연동되지 않았습니다. API 구조(<code>/tts/*</code>)는 <p class="muted small">XTTS는 제로샷 보이스 클로닝 모델이라, 합성하기 전에 참조 음성을
준비되어 있으며, 엔진이 연결되면 이 화면에서 텍스트를 음성으로 변환할 수 있습니다.</p> 먼저 등록해야 합니다(5~15초 분량의 깨끗한 음성 권장).</p>
<div id="tts-voice-list" style="margin:10px 0">
<div class="muted small">불러오는 중...</div>
</div>
<div class="grid2">
<label>보이스 이름
<input type="text" id="tts-voice-name" placeholder="예: my-voice">
</label>
<label>참조 음성 파일
<input type="file" id="tts-voice-file" accept="audio/*">
</label>
</div>
<div class="actions">
<button type="button" id="tts-voice-upload-btn">보이스 업로드</button>
</div>
</section>
<section class="card">
<form id="tts-form">
<label>텍스트
<textarea id="tts-text" rows="5" placeholder="음성으로 변환할 텍스트를 입력하세요." required></textarea>
</label>
<div class="grid3">
<label>백엔드
<select id="tts-backend" name="backend"></select>
</label>
<label>언어
<select id="tts-language" name="language"></select>
</label>
<label>보이스
<select id="tts-voice-select" name="voice"></select>
</label>
</div>
<div class="actions">
<button type="submit" id="tts-submit-btn">합성 실행</button>
</div>
</form>
</section>
<section class="card">
<h2>상태</h2>
<pre id="tts-status">대기 중</pre>
</section>
<section class="card" id="tts-result-section" style="display:none">
<h2>결과</h2>
<audio controls class="media-player" id="tts-result-audio"></audio>
<div class="actions" style="margin-top:10px">
<button type="button" id="tts-dl-btn">오디오 다운로드</button>
</div>
</section> </section>
</div> </div>
<div id="tab-tts-history" class="tab-panel" style="display:none"> <div id="tab-tts-history" class="tab-panel" style="display:none">
<section class="card"> <section class="card">
<h2>처리 내역</h2> <div class="history-toolbar">
<div class="muted" style="padding:16px">아직 처리 내역이 없습니다.</div> <h2 style="margin:0">처리 내역</h2>
<button type="button" id="tts-history-refresh-btn" class="btn-secondary">새로고침</button>
</div>
</section> </section>
<div id="tts-history-list">
<div class="muted" style="padding:16px">내역을 불러오는 중...</div>
</div>
<div id="tts-history-detail" style="display:none">
<section class="card">
<div class="detail-header">
<button type="button" id="tts-history-back-btn" class="btn-secondary">← 목록으로</button>
<span id="tts-detail-title" class="detail-title"></span>
<button type="button" id="tts-detail-delete-btn" class="btn-danger">삭제</button>
</div>
</section>
<section class="card">
<h2>오디오</h2>
<audio controls class="media-player" id="tts-detail-audio"></audio>
</section>
<section class="card">
<h2>정보</h2>
<div id="tts-detail-meta" class="meta-grid"></div>
</section>
<section class="card">
<h2>텍스트</h2>
<textarea id="tts-detail-text" rows="8" readonly></textarea>
</section>
</div>
</div> </div>
</div> </div>
</div> </div>

13
envs/xtts.txt Normal file
View File

@@ -0,0 +1,13 @@
# torch는 --system-site-packages로 base image에서 상속
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
soundfile>=0.12.0
# coqui-tts: 원래 Coqui사의 TTS 패키지는 2024년 회사 해체로 unmaintained.
# 동일한 TTS.api import 경로를 유지하는 커뮤니티 후속 패키지를 사용한다.
coqui-tts
# coqui-tts는 transformers>=4.57만 선언해서 pip이 호환 안 되는 5.x를 깔아버린다
# (transformers 5.x에서 transformers.pytorch_utils.isin_mps_friendly가 제거되어
# ImportError 발생). 마지막 4.x로 고정.
transformers==4.57.6
huggingface_hub<1.0

View File

@@ -13,6 +13,9 @@ Volume=/srv/asr/models-cache:/srv/asr/models-cache:z
Volume=/srv/asr/uploads:/srv/asr/uploads:z Volume=/srv/asr/uploads:/srv/asr/uploads:z
Volume=/srv/asr/results:/srv/asr/results:z Volume=/srv/asr/results:/srv/asr/results:z
Volume=/srv/asr/custom-models:/srv/asr/custom-models:z Volume=/srv/asr/custom-models:/srv/asr/custom-models:z
Volume=/srv/tts/models-cache:/srv/tts/models-cache:z
Volume=/srv/tts/voices:/srv/tts/voices:z
Volume=/srv/tts/results:/srv/tts/results:z
EnvironmentFile=/srv/asr/env/asr.env EnvironmentFile=/srv/asr/env/asr.env
AddDevice=nvidia.com/gpu=all AddDevice=nvidia.com/gpu=all
HealthCmd=curl -sf http://127.0.0.1:8000/health HealthCmd=curl -sf http://127.0.0.1:8000/health