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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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] = {}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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)
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
13
envs/xtts.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user