from __future__ import annotations import json import re from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional import httpx from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from tts.config import ( DEFAULT_TTS_BACKEND, DEFAULT_TTS_LANGUAGE, TTS_RESULT_DIR, TTS_VOICE_DIR, XTTS_URL, ensure_runtime_dirs, ) router = APIRouter() BACKENDS = { "xtts": XTTS_URL, } # XTTS-v2가 지원하는 언어 (Coqui 공식 문서 기준) XTTS_LANGUAGES = [ "ko", "en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru", "nl", "cs", "ar", "zh-cn", "ja", "hu", "hi", ] _SAFE_NAME = re.compile(r"[^a-zA-Z0-9_\-가-힣]+") def _sanitize_voice_name(name: str) -> str: name = name.strip() if not name: raise HTTPException(status_code=400, detail="보이스 이름이 비어 있습니다.") return _SAFE_NAME.sub("_", name) def _find_voice_path(name: str) -> Optional[Path]: safe = _sanitize_voice_name(name) matches = list(TTS_VOICE_DIR.glob(f"{safe}.*")) return matches[0] if matches else None # ─── Config ──────────────────────────────────────────────────────────────────── @router.get("/config") def config() -> Dict[str, Any]: return { "default_backend": DEFAULT_TTS_BACKEND, "default_language": DEFAULT_TTS_LANGUAGE, "backends": { "xtts": {"languages": XTTS_LANGUAGES}, }, } # ─── Voices (레퍼런스 보이스 관리) ─────────────────────────────────────────────── @router.get("/voices") def list_voices() -> List[Dict[str, str]]: ensure_runtime_dirs() voices = [] for p in sorted(TTS_VOICE_DIR.iterdir()): if p.is_file(): voices.append({"name": p.stem, "filename": p.name}) return voices @router.post("/voices") async def upload_voice(file: UploadFile = File(...), name: str = Form(...)) -> Dict[str, str]: ensure_runtime_dirs() safe_name = _sanitize_voice_name(name) suffix = Path(file.filename or "voice.wav").suffix or ".wav" # 같은 이름의 기존 파일(다른 확장자 포함) 제거 후 새로 저장 for old in TTS_VOICE_DIR.glob(f"{safe_name}.*"): old.unlink(missing_ok=True) dest = TTS_VOICE_DIR / f"{safe_name}{suffix}" dest.write_bytes(await file.read()) return {"name": safe_name, "filename": dest.name} @router.delete("/voices/{name}") def delete_voice(name: str) -> Dict[str, str]: path = _find_voice_path(name) if not path: raise HTTPException(status_code=404, detail="보이스를 찾을 수 없습니다.") path.unlink(missing_ok=True) return {"status": "deleted", "name": name} @router.get("/voices/{name}/audio") def serve_voice_audio(name: str) -> FileResponse: path = _find_voice_path(name) if not path: raise HTTPException(status_code=404, detail="보이스를 찾을 수 없습니다.") return FileResponse(path) # ─── History ────────────────────────────────────────────────────────────────── @router.get("/history") def list_history() -> List[Dict[str, Any]]: ensure_runtime_dirs() records = [] for p in sorted(TTS_RESULT_DIR.glob("*.json"), reverse=True): try: data = json.loads(p.read_text(encoding="utf-8")) records.append({ "id": p.stem, "backend": data.get("backend", ""), "language": data.get("language", ""), "voice": data.get("voice", ""), "text_preview": (data.get("text", "") or "")[:120], "duration": data.get("duration"), }) except Exception: continue return records @router.get("/history/{record_id}") def get_history(record_id: str) -> Dict[str, Any]: safe_id = Path(record_id).name p = TTS_RESULT_DIR / f"{safe_id}.json" if not p.exists(): raise HTTPException(status_code=404, detail="Record not found") return json.loads(p.read_text(encoding="utf-8")) @router.delete("/history/{record_id}") def delete_history(record_id: str) -> Dict[str, str]: safe_id = Path(record_id).name p = TTS_RESULT_DIR / f"{safe_id}.json" if not p.exists(): raise HTTPException(status_code=404, detail="Record not found") try: data = json.loads(p.read_text(encoding="utf-8")) audio_path = data.get("audio_file", "") if audio_path: ap = Path(audio_path) if ap.exists() and ap.is_relative_to(TTS_RESULT_DIR): ap.unlink(missing_ok=True) except Exception: pass p.unlink(missing_ok=True) return {"status": "deleted", "id": safe_id} @router.get("/audio/{filename}") def serve_audio(filename: str) -> FileResponse: safe_name = Path(filename).name p = TTS_RESULT_DIR / safe_name if not p.exists(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(p) # ─── Synthesize ─────────────────────────────────────────────────────────────── @router.post("/synthesize") async def synthesize( text: str = Form(...), backend: str = Form(DEFAULT_TTS_BACKEND), language: str = Form(DEFAULT_TTS_LANGUAGE), voice: str = Form(...), ) -> Dict[str, Any]: if backend not in BACKENDS: raise HTTPException(status_code=400, detail=f"Unsupported backend: {backend}") if not text.strip(): raise HTTPException(status_code=400, detail="text가 비어 있습니다.") voice_path = _find_voice_path(voice) if not voice_path: raise HTTPException(status_code=404, detail=f"보이스 '{voice}'를 찾을 수 없습니다. 먼저 보이스를 업로드하세요.") ensure_runtime_dirs() worker_url = BACKENDS[backend] if backend == "xtts": data = { "text": text, "language": language, "speaker_wav": str(voice_path), } else: data = {"text": text, "language": language} async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=60.0)) as client: response = await client.post(f"{worker_url}/synthesize", data=data) if response.status_code >= 400: raise HTTPException(status_code=response.status_code, detail=f"Backend error: {response.text}") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") audio_path = TTS_RESULT_DIR / f"{timestamp}.wav" audio_path.write_bytes(response.content) duration = response.headers.get("x-audio-duration") payload = { "backend": backend, "language": language, "voice": voice, "text": text, "duration": float(duration) if duration else None, "audio_file": str(audio_path), "_timestamp": timestamp, } result_path = TTS_RESULT_DIR / f"{timestamp}.json" result_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") payload["id"] = timestamp payload["audio_url"] = f"/tts/audio/{audio_path.name}" return payload