From acdf098c4140f90bc2a9c5eee738dbf881697f32 Mon Sep 17 00:00:00 2001 From: du5t Date: Thu, 18 Jun 2026 18:46:45 +0900 Subject: [PATCH] Add XTTS-v2 as first TTS backend, extensible for more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Containerfile | 5 + app/asr/workers/faster_whisper_worker.py | 6 + app/asr/workers/qwen3_worker.py | 6 + app/main.py | 6 +- app/supervisord.conf | 11 + app/tts/config.py | 12 +- app/tts/router.py | 212 ++++++++++++++++- app/tts/workers/xtts_worker.py | 117 +++++++++ app/ui/assets/css/styles.css | 14 ++ app/ui/assets/js/tts.js | 290 ++++++++++++++++++++++- app/ui/index.html | 90 ++++++- envs/xtts.txt | 13 + quadlet/speech.container | 3 + 13 files changed, 761 insertions(+), 24 deletions(-) create mode 100644 app/tts/workers/xtts_worker.py create mode 100644 envs/xtts.txt diff --git a/Containerfile b/Containerfile index 107edc6..87c865e 100644 --- a/Containerfile +++ b/Containerfile @@ -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 -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/ RUN chmod +x /app/start.sh diff --git a/app/asr/workers/faster_whisper_worker.py b/app/asr/workers/faster_whisper_worker.py index 7bd2ba3..c60e455 100644 --- a/app/asr/workers/faster_whisper_worker.py +++ b/app/asr/workers/faster_whisper_worker.py @@ -22,6 +22,12 @@ from asr.config import ( 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") _MODEL_CACHE: Dict[str, WhisperModel] = {} _DIARIZATION_PIPELINE: Any = None diff --git a/app/asr/workers/qwen3_worker.py b/app/asr/workers/qwen3_worker.py index 51b6864..3bf0640 100644 --- a/app/asr/workers/qwen3_worker.py +++ b/app/asr/workers/qwen3_worker.py @@ -14,6 +14,12 @@ import sys sys.path.insert(0, "/app") 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") _PIPE_CACHE: Dict[str, Any] = {} diff --git a/app/main.py b/app/main.py index 820f888..6eaa519 100644 --- a/app/main.py +++ b/app/main.py @@ -9,11 +9,12 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles 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 ws_router as asr_ws_router from core.auth import SESSION_SECRET_KEY, require_login 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 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") def startup() -> None: - ensure_runtime_dirs() + ensure_asr_dirs() + ensure_tts_dirs() if UI_DIR.exists(): app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui") app.mount("/assets", StaticFiles(directory=str(UI_DIR / "assets")), name="assets") diff --git a/app/supervisord.conf b/app/supervisord.conf index 83d1849..9693e8a 100644 --- a/app/supervisord.conf +++ b/app/supervisord.conf @@ -25,6 +25,17 @@ stderr_logfile=/dev/fd/2 stderr_logfile_maxbytes=0 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] command=/opt/venvs/gateway/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000 directory=/app diff --git a/app/tts/config.py b/app/tts/config.py index 9f86238..2d6e583 100644 --- a/app/tts/config.py +++ b/app/tts/config.py @@ -6,7 +6,15 @@ from core.config import env_str 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_UPLOAD_DIR = TTS_BASE_DIR / "uploads" +TTS_VOICE_DIR = TTS_BASE_DIR / "voices" 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) diff --git a/app/tts/router.py b/app/tts/router.py index a7cbd30..4fd0d1f 100644 --- a/app/tts/router.py +++ b/app/tts/router.py @@ -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 diff --git a/app/tts/workers/xtts_worker.py b/app/tts/workers/xtts_worker.py new file mode 100644 index 0000000..21f7497 --- /dev/null +++ b/app/tts/workers/xtts_worker.py @@ -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) diff --git a/app/ui/assets/css/styles.css b/app/ui/assets/css/styles.css index 10013b8..9bd3ea8 100644 --- a/app/ui/assets/css/styles.css +++ b/app/ui/assets/css/styles.css @@ -296,6 +296,20 @@ pre { 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) { .grid2, .grid3 { grid-template-columns: 1fr; } .mic-row { flex-direction: column; } diff --git a/app/ui/assets/js/tts.js b/app/ui/assets/js/tts.js index c0459b6..db6553c 100644 --- a/app/ui/assets/js/tts.js +++ b/app/ui/assets/js/tts.js @@ -1,15 +1,293 @@ -// TTS tab is a structural placeholder until a synthesis backend is wired in. -// This just confirms the /tts router is reachable; the UI itself is a static -// "coming soon" card (see index.html). +// ─── Config ──────────────────────────────────────────────────────────────── + +let TTS_CONFIG = null; async function loadTtsConfig() { try { const r = await fetch('/tts/config'); - const cfg = await r.json(); - console.log('[tts] config', cfg); + TTS_CONFIG = await r.json(); } 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 = `
불러오기 실패: ${esc(e.message)}
`; + return; + } + renderTtsVoiceList(voices); + renderTtsVoiceSelect(voices); +} + +function renderTtsVoiceList(voices) { + if (!voices.length) { + ttsVoiceList.innerHTML = '
등록된 보이스가 없습니다. 먼저 참조 음성을 업로드하세요.
'; + return; + } + ttsVoiceList.innerHTML = ''; + for (const v of voices) { + const row = document.createElement('div'); + row.className = 'voice-row'; + row.innerHTML = ` + ${esc(v.name)} + + + `; + 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 = '
불러오는 중...
'; + 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 = `
오류: ${esc(e.message)}
`; + } +} + +function renderTtsHistoryList(records) { + if (!records.length) { + ttsHistoryList.innerHTML = '
처리 내역이 없습니다.
'; + return; + } + let html = '
' + + '' + + ''; + for (const rec of records) { + const dur = rec.duration != null ? `${rec.duration.toFixed(1)}s` : '-'; + html += ` + + + + + + + + `; + } + html += '
시각백엔드언어보이스길이미리보기
${esc(fmtTimestamp(rec.id))}${esc(rec.backend || '-')}${esc(rec.language || '-')}${esc(rec.voice || '-')}${dur}${esc(rec.text_preview || '')}
'; + 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]) => + `
${esc(k)}${esc(v)}
` + ).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(); +loadTtsVoices(); diff --git a/app/ui/index.html b/app/ui/index.html index b3614ec..f8a78d6 100644 --- a/app/ui/index.html +++ b/app/ui/index.html @@ -320,17 +320,97 @@
-

TTS 기능 준비 중

-

음성 합성 백엔드는 아직 연동되지 않았습니다. API 구조(/tts/*)는 - 준비되어 있으며, 엔진이 연결되면 이 화면에서 텍스트를 음성으로 변환할 수 있습니다.

+

보이스

+

XTTS는 제로샷 보이스 클로닝 모델이라, 합성하기 전에 참조 음성을 + 먼저 등록해야 합니다(5~15초 분량의 깨끗한 음성 권장).

+
+
불러오는 중...
+
+
+ + +
+
+ +
+
+ +
+
+ +
+ + + +
+
+ +
+
+
+ +
+

상태

+
대기 중
+
+ +
diff --git a/envs/xtts.txt b/envs/xtts.txt new file mode 100644 index 0000000..02febc5 --- /dev/null +++ b/envs/xtts.txt @@ -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 diff --git a/quadlet/speech.container b/quadlet/speech.container index 813747b..a60274c 100644 --- a/quadlet/speech.container +++ b/quadlet/speech.container @@ -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/results:/srv/asr/results: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 AddDevice=nvidia.com/gpu=all HealthCmd=curl -sf http://127.0.0.1:8000/health