MarneMorgan/piper_ffmpeg
0
1import os2import subprocess3import tempfile4import uuid5from typing import Optional, List6 7import requests8from fastapi import FastAPI, HTTPException, BackgroundTasks9from fastapi.responses import FileResponse10from pydantic import BaseModel11 12app = FastAPI(title="Piper + FFmpeg (Single App)")13 14# Cache voice models locally (HF storage is ephemeral; cache reduces repeat downloads)15MODEL_DIR = os.getenv("MODEL_DIR", "/tmp/piper_models")16os.makedirs(MODEL_DIR, exist_ok=True)17 18# -----------------------------19# English-only voice catalog20# -----------------------------21VOICE_CATALOG = {22 "en_US-amy": {23 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx",24 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/amy/medium/en_US-amy-medium.onnx.json",25 },26 "en_US-ryan": {27 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/medium/en_US-ryan-medium.onnx",28 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/ryan/medium/en_US-ryan-medium.onnx.json",29 },30 "en_GB-alan": {31 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/medium/en_GB-alan-medium.onnx",32 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/alan/medium/en_GB-alan-medium.onnx.json",33 },34 "en_GB-sarah": {35 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/sarah/medium/en_GB-sarah-medium.onnx",36 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/sarah/medium/en_GB-sarah-medium.onnx.json",37 },38 "en_AU-nat": {39 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_AU/nat/medium/en_AU-nat-medium.onnx",40 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_AU/nat/medium/en_AU-nat-medium.onnx.json",41 },42 "en_US-joe": {43 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/joe/medium/en_US-joe-medium.onnx",44 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/joe/medium/en_US-joe-medium.onnx.json",45 },46 "en_US-kathleen": {47 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/kathleen/medium/en_US-kathleen-medium.onnx",48 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/kathleen/medium/en_US-kathleen-medium.onnx.json",49 },50 "en_US-danny": {51 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/danny/medium/en_US-danny-medium.onnx",52 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/danny/medium/en_US-danny-medium.onnx.json",53 },54 "en_GB-jenny": {55 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/jenny/medium/en_GB-jenny-medium.onnx",56 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_GB/jenny/medium/en_GB-jenny-medium.onnx.json",57 },58 "en_US-lessac": {59 "onnx": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx",60 "json": "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json",61 },62}63 64def _download(url: str, path: str, timeout: int = 180):65 r = requests.get(url, timeout=timeout)66 r.raise_for_status()67 with open(path, "wb") as f:68 f.write(r.content)69 70def ensure_voice_model(voice_id: str) -> str:71 if voice_id not in VOICE_CATALOG:72 raise HTTPException(404, f"Unknown voiceId: {voice_id}")73 74 onnx_path = os.path.join(MODEL_DIR, f"{voice_id}.onnx")75 json_path = os.path.join(MODEL_DIR, f"{voice_id}.onnx.json")76 77 if not os.path.exists(onnx_path):78 _download(VOICE_CATALOG[voice_id]["onnx"], onnx_path)79 80 if "json" in VOICE_CATALOG[voice_id] and not os.path.exists(json_path):81 try:82 _download(VOICE_CATALOG[voice_id]["json"], json_path)83 except Exception:84 pass85 86 return onnx_path87 88def _cleanup_files(paths: List[str]):89 for p in paths:90 try:91 if p and os.path.exists(p):92 os.remove(p)93 except Exception:94 pass95 96# -----------------------------97# Routes98# -----------------------------99@app.get("/")100def root():101 return {102 "ok": True,103 "service": "piper_ffmpeg",104 "endpoints": ["/docs", "/health", "/voices", "/tts", "/render"]105 }106 107@app.get("/health")108def health():109 def has(cmd):110 return subprocess.run(["bash", "-lc", f"command -v {cmd} >/dev/null 2>&1"]).returncode == 0111 return {"ok": True, "ffmpeg": has("ffmpeg"), "piper": has("piper")}112 113@app.get("/voices")114def voices():115 return {"voices": sorted(list(VOICE_CATALOG.keys()))}116 117# -----------------------------118# TTS (Piper -> mp3/wav -> returns file)119# -----------------------------120class TTSReq(BaseModel):121 jobId: str122 voiceId: str123 text: str124 format: str = "mp3" # mp3|wav125 length_scale: float = 0.95126 noise_scale: float = 0.75127 noise_w: float = 0.80128 fx: bool = True129 130@app.post("/tts")131def tts(req: TTSReq, background_tasks: BackgroundTasks):132 if not req.text.strip():133 raise HTTPException(400, "text required")134 135 model_path = ensure_voice_model(req.voiceId)136 fmt = req.format.lower().strip()137 138 # IMPORTANT: output must NOT live inside a TemporaryDirectory that auto-deletes.139 # We create stable files under /tmp and delete them AFTER response is sent.140 uid = uuid.uuid4().hex141 wav_path = f"/tmp/tts_{req.jobId}_{uid}.wav"142 mp3_path = f"/tmp/tts_{req.jobId}_{uid}.mp3"143 144 # 1) Piper outputs WAV145 p = subprocess.run(146 ["piper", "--model", model_path, "--output_file", wav_path],147 input=req.text,148 text=True,149 capture_output=True150 )151 if p.returncode != 0:152 _cleanup_files([wav_path, mp3_path])153 raise HTTPException(500, f"piper failed: {p.stderr[-1500:]}")154 155 # 2) Optionally convert156 if fmt == "wav":157 out_path = wav_path158 media_type = "audio/wav"159 filename = f"{req.jobId}_{req.voiceId}.wav"160 cleanup = [wav_path] # delete after send161 elif fmt == "mp3":162 c = subprocess.run(163 ["ffmpeg", "-y", "-i", wav_path, "-codec:a", "libmp3lame", "-q:a", "3", mp3_path],164 capture_output=True,165 text=True166 )167 if c.returncode != 0 or not os.path.exists(mp3_path):168 _cleanup_files([wav_path, mp3_path])169 raise HTTPException(500, f"ffmpeg mp3 convert failed: {c.stderr[-1500:]}")170 out_path = mp3_path171 media_type = "audio/mpeg"172 filename = f"{req.jobId}_{req.voiceId}.mp3"173 cleanup = [wav_path, mp3_path] # delete after send174 else:175 _cleanup_files([wav_path, mp3_path])176 raise HTTPException(400, "format must be mp3 or wav")177 178 background_tasks.add_task(_cleanup_files, cleanup)179 return FileResponse(path=out_path, media_type=media_type, filename=filename)180 181# -----------------------------182# Render (FFmpeg “wonders” -> returns mp4)183# -----------------------------184class RenderReq(BaseModel):185 jobId: str186 preset: str = "shorts_hypercut" # shorts_hypercut|story_cinematic|facts_clean187 clipUrls: List[str]188 voiceUrl: Optional[str] = None189 srt: Optional[str] = None # optional captions190 191@app.post("/render")192def render(req: RenderReq, background_tasks: BackgroundTasks):193 if not req.clipUrls:194 raise HTTPException(400, "clipUrls required")195 196 uid = uuid.uuid4().hex197 work_dir = f"/tmp/render_{req.jobId}_{uid}"198 os.makedirs(work_dir, exist_ok=True)199 200 clips_dir = os.path.join(work_dir, "clips")201 os.makedirs(clips_dir, exist_ok=True)202 203 concat_txt = os.path.join(work_dir, "concat.txt")204 merged = os.path.join(work_dir, "merged.mp4")205 final_mp4 = os.path.join(work_dir, f"final_{req.preset}.mp4")206 207 try:208 # Download clips209 with open(concat_txt, "w") as f:210 for i, url in enumerate(req.clipUrls):211 p = os.path.join(clips_dir, f"clip_{i}.mp4")212 r = requests.get(url, timeout=120)213 r.raise_for_status()214 with open(p, "wb") as w:215 w.write(r.content)216 f.write(f"file '{p}'\n")217 218 # concat with fallback219 r1 = subprocess.run(220 ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_txt, "-c", "copy", merged],221 capture_output=True, text=True222 )223 if r1.returncode != 0:224 r2 = subprocess.run(225 ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_txt,226 "-c:v", "libx264", "-crf", "19", "-preset", "veryfast",227 "-c:a", "aac", "-b:a", "128k", merged],228 capture_output=True, text=True229 )230 if r2.returncode != 0:231 raise HTTPException(500, f"concat failed: {r2.stderr[-1500:]}")232 233 # Caption file (optional)234 srt_path = None235 if req.srt:236 srt_path = os.path.join(work_dir, "captions.srt")237 with open(srt_path, "w", encoding="utf-8") as w:238 w.write(req.srt)239 240 # Filter stack241 vf = "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,eq=contrast=1.08:saturation=1.12,unsharp=5:5:0.7:5:5:0.0,fps=30"242 if req.preset == "story_cinematic":243 vf += ",vignette=PI/6,eq=contrast=1.10:saturation=1.05"244 elif req.preset == "facts_clean":245 vf += ",eq=contrast=1.06:saturation=1.06"246 247 if srt_path:248 vf += f",subtitles={srt_path}:force_style='FontName=Arial,FontSize=20,Outline=3,Shadow=1,Alignment=2'"249 250 if req.voiceUrl:251 voice_path = os.path.join(work_dir, "voice.mp3")252 vr = requests.get(req.voiceUrl, timeout=120)253 vr.raise_for_status()254 with open(voice_path, "wb") as w:255 w.write(vr.content)256 257 cmd = [258 "ffmpeg", "-y", "-i", merged, "-i", voice_path,259 "-filter_complex", f"[0:v]{vf}[v];[1:a]loudnorm=I=-16:TP=-1.5:LRA=11[a]",260 "-map", "[v]", "-map", "[a]",261 "-shortest", "-movflags", "+faststart", "-pix_fmt", "yuv420p",262 final_mp4263 ]264 else:265 cmd = ["ffmpeg", "-y", "-i", merged, "-vf", vf, "-movflags", "+faststart", "-pix_fmt", "yuv420p", final_mp4]266 267 rr = subprocess.run(cmd, capture_output=True, text=True)268 if rr.returncode != 0 or not os.path.exists(final_mp4):269 raise HTTPException(500, f"render failed: {rr.stderr[-1500:]}")270 271 # cleanup AFTER response is sent272 background_tasks.add_task(lambda: subprocess.run(["bash", "-lc", f"rm -rf {work_dir} >/dev/null 2>&1"]))273 return FileResponse(path=final_mp4, media_type="video/mp4", filename=f"{req.jobId}_{req.preset}.mp4")274 275 except HTTPException:276 # cleanup on error too277 subprocess.run(["bash", "-lc", f"rm -rf {work_dir} >/dev/null 2>&1"])278 raise279 except Exception as e:280 subprocess.run(["bash", "-lc", f"rm -rf {work_dir} >/dev/null 2>&1"])281 raise HTTPException(500, str(e))