""" 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/.wav , /files/samples/.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" @app.get("/api/v1/health") 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, } @app.post("/api/v1/warmup") def warmup(): """โหลดโมเดลล่วงหน้า (เรียกครั้งเดียวเพื่อให้ /generate ครั้งแรกไม่ช้า)""" eng = get_engine() return {"loaded": eng is not None, "engine": eng.name if eng else None, "error": _engine_err} @app.post("/api/v1/voices") 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] @app.get("/api/v1/voices") def list_voices(): return {"voices": list(load_db().values())} class GenReq(BaseModel): voice_id: str text: str emotion: str = "neutral" speed: float = 1.0 @app.post("/api/v1/generate") 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", } @app.delete("/api/v1/voices/{voice_id}") 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}