Spaces:
Sleeping
Sleeping
| """ | |
| VoiceClone AI — Backend API (FastAPI) | |
| เอนด์พอยต์ตาม PRD: /api/v1/generate, /api/v1/voices, /api/v1/health | |
| โคลนเสียงจริงด้วย F5-TTS / XTTS-v2 (ดู tts_engine.py) | |
| รัน: uvicorn app:app --host 0.0.0.0 --port 8000 --reload | |
| ต้องมี ffmpeg ในระบบ (แปลง webm ที่อัดจากเบราว์เซอร์ → wav) | |
| """ | |
| import os | |
| # กัน PYTHONHASHSEED='' (ค่าว่าง) ในเชลล์ ทำให้ subprocess ของ Python (torch/vocos) crash | |
| if not os.environ.get("PYTHONHASHSEED"): | |
| os.environ["PYTHONHASHSEED"] = "0" | |
| import json | |
| import shutil | |
| import subprocess | |
| import uuid | |
| from pathlib import Path | |
| from fastapi import FastAPI, File, Form, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel | |
| import tts_engine | |
| ROOT = Path(__file__).parent | |
| STORAGE = ROOT / "storage" | |
| SAMPLES = STORAGE / "samples" | |
| AUDIO = STORAGE / "audio" | |
| for _p in (SAMPLES, AUDIO): | |
| _p.mkdir(parents=True, exist_ok=True) | |
| DB_FILE = STORAGE / "voices.json" | |
| def load_db() -> dict: | |
| if DB_FILE.exists(): | |
| return json.loads(DB_FILE.read_text(encoding="utf-8")) | |
| return {} | |
| def save_db(db: dict) -> None: | |
| DB_FILE.write_text(json.dumps(db, ensure_ascii=False, indent=2), encoding="utf-8") | |
| app = FastAPI(title="VoiceClone AI Backend", version="1.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], | |
| ) | |
| # เสิร์ฟไฟล์เสียง: /files/audio/<id>.wav , /files/samples/<id>.wav | |
| app.mount("/files", StaticFiles(directory=str(STORAGE)), name="files") | |
| _engine = None | |
| _engine_err = None | |
| def get_engine(): | |
| """โหลดเอนจินแบบ lazy (โมเดลหนัก โหลดครั้งแรกที่เรียกใช้)""" | |
| global _engine, _engine_err | |
| if _engine is None and _engine_err is None: | |
| try: | |
| _engine = tts_engine.load_engine() | |
| except Exception as e: | |
| _engine_err = str(e) | |
| return _engine | |
| def to_wav(src: Path, dst: Path, max_seconds: int = 12) -> None: | |
| """แปลงไฟล์เสียงใด ๆ (webm/opus จากเบราว์เซอร์) → wav 24kHz mono ด้วย ffmpeg | |
| ตัดให้ไม่เกิน max_seconds เพราะ F5-TTS ใช้เสียงต้นฉบับสั้น (<12s) ได้ผลดีที่สุด""" | |
| cmd = ["ffmpeg", "-y", "-i", str(src), "-ar", "24000", "-ac", "1"] | |
| if max_seconds: | |
| cmd += ["-t", str(max_seconds)] | |
| cmd += [str(dst)] | |
| subprocess.run(cmd, check=True, capture_output=True) | |
| def _configured_engine_name() -> str: | |
| import os | |
| pref = os.getenv("TTS_ENGINE", "auto").lower() | |
| if pref in ("xtts", "coqui", "openvoice"): | |
| return "XTTS-v2" | |
| # ถ้ามี checkpoint ไทยอยู่ในเครื่อง รายงานเป็น F5-TTS-THAI | |
| if (ROOT / "models" / "model_1000000.pt").exists() or os.getenv("F5_CKPT_FILE"): | |
| return "F5-TTS-THAI" | |
| return "F5-TTS" | |
| def health(): | |
| # เบา — ไม่โหลดโมเดล (โมเดลโหลดตอน /generate ครั้งแรก) เพื่อให้ตอบเร็ว | |
| return { | |
| "status": "ok", | |
| "engine": _engine.name if _engine else _configured_engine_name(), | |
| "model": getattr(_engine, "model_name", None) if _engine else None, | |
| "ready": True, | |
| "loaded": _engine is not None, | |
| "error": _engine_err, | |
| } | |
| def warmup(): | |
| """โหลดโมเดลล่วงหน้า (เรียกครั้งเดียวเพื่อให้ /generate ครั้งแรกไม่ช้า)""" | |
| eng = get_engine() | |
| return {"loaded": eng is not None, "engine": eng.name if eng else None, "error": _engine_err} | |
| async def create_voice( | |
| sample: UploadFile = File(...), | |
| name: str = Form(...), | |
| language: str = Form("th"), | |
| gender: str = Form("auto"), | |
| ref_text: str = Form(""), | |
| ): | |
| vid = "voice_" + uuid.uuid4().hex[:12] | |
| raw = SAMPLES / f"{vid}.orig" | |
| with raw.open("wb") as f: | |
| shutil.copyfileobj(sample.file, f) | |
| wav = SAMPLES / f"{vid}.wav" | |
| try: | |
| to_wav(raw, wav) | |
| except FileNotFoundError: | |
| raise HTTPException(500, "ไม่พบ ffmpeg — กรุณาติดตั้ง ffmpeg ก่อน") | |
| except subprocess.CalledProcessError as e: | |
| raise HTTPException(500, f"แปลงไฟล์เสียงไม่สำเร็จ: {e.stderr.decode('utf-8','ignore')[:300]}") | |
| finally: | |
| raw.unlink(missing_ok=True) | |
| db = load_db() | |
| db[vid] = { | |
| "voice_id": vid, "name": name, "language": language, | |
| "gender": gender, "ref_text": ref_text, "sample": f"{vid}.wav", | |
| "sample_url": f"/files/samples/{vid}.wav", | |
| } | |
| save_db(db) | |
| return db[vid] | |
| def list_voices(): | |
| return {"voices": list(load_db().values())} | |
| class GenReq(BaseModel): | |
| voice_id: str | |
| text: str | |
| emotion: str = "neutral" | |
| speed: float = 1.0 | |
| def generate(req: GenReq): | |
| db = load_db() | |
| voice = db.get(req.voice_id) | |
| if not voice: | |
| raise HTTPException(404, "ไม่พบโปรไฟล์เสียงนี้") | |
| if not (req.text or "").strip(): | |
| raise HTTPException(400, "ข้อความว่าง") | |
| eng = get_engine() | |
| if not eng: | |
| raise HTTPException(503, f"เอนจิน TTS ยังไม่พร้อม: {_engine_err}") | |
| gid = "gen_" + uuid.uuid4().hex[:12] | |
| out = AUDIO / f"{gid}.wav" | |
| ref = SAMPLES / voice["sample"] | |
| try: | |
| eng.clone( | |
| text=req.text, ref_wav=ref, ref_text=voice.get("ref_text", ""), | |
| out_path=out, speed=req.speed, emotion=req.emotion, | |
| language=voice.get("language", "th"), | |
| ) | |
| except Exception as e: | |
| raise HTTPException(500, f"สร้างเสียงไม่สำเร็จ: {e}") | |
| return { | |
| "generation_id": gid, | |
| "audio_url": f"/files/audio/{gid}.wav", | |
| "status": "completed", | |
| } | |
| def delete_voice(voice_id: str): | |
| db = load_db() | |
| v = db.pop(voice_id, None) | |
| if not v: | |
| raise HTTPException(404, "ไม่พบเสียง") | |
| (SAMPLES / v["sample"]).unlink(missing_ok=True) | |
| save_db(db) | |
| return {"deleted": voice_id} | |