paomailbox's picture
Deploy VoiceClone TH API (F5-TTS-THAI)
d14f1d9 verified
Raw
History Blame Contribute Delete
6.78 kB
"""
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"
@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}