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(fmtTimestamp(rec.id))} | +${esc(rec.backend || '-')} | +${esc(rec.language || '-')} | +${esc(rec.voice || '-')} | +${dur} | +${esc(rec.text_preview || '')} | ++ |
음성 합성 백엔드는 아직 연동되지 않았습니다. API 구조(/tts/*)는
- 준비되어 있으며, 엔진이 연결되면 이 화면에서 텍스트를 음성으로 변환할 수 있습니다.
XTTS는 제로샷 보이스 클로닝 모델이라, 합성하기 전에 참조 음성을 + 먼저 등록해야 합니다(5~15초 분량의 깨끗한 음성 권장).
+대기 중+