Scriptorium / modal_app.py
mattkevan's picture
fix: harden modal render status polling
3921761
Raw
History Blame Contribute Delete
9.15 kB
import base64
import os
import tempfile
from pathlib import Path
from typing import Any, Dict, List
from uuid import uuid4
import fastapi
import modal
from fastapi import Header, HTTPException
from fastapi.responses import Response
from backend.magpie_adapter import MagpieAdapter
from backend.omnivoice_adapter import OmniVoiceAdapter
from backend.synthesis_catalog import MODAL_BACKEND
from backend.types import VoiceConfig
app = modal.App("scriptorium-tts")
image = (
modal.Image.debian_slim(python_version="3.12")
.apt_install("git")
.pip_install(
"fastapi[standard]",
"numpy>=1.26.0",
"soundfile>=0.13.0",
"torch>=2.8.0",
"torchaudio>=2.8.0",
"omnivoice>=0.1.5",
"requests>=2.32.0",
"huggingface_hub>=0.33.0",
"nemo_toolkit[tts]@git+https://github.com/NVIDIA/NeMo.git@main",
"kaldialign",
)
.add_local_python_source("backend")
)
jobs = modal.Dict.from_name("scriptorium-modal-jobs", create_if_missing=True)
artifacts = modal.Dict.from_name("scriptorium-modal-artifacts", create_if_missing=True)
web_app = fastapi.FastAPI()
def _check_auth(header: str | None) -> None:
expected = os.getenv("SCRIPTORIUM_MODAL_SHARED_SECRET", "").strip()
if not expected:
return
token = (header or "").removeprefix("Bearer ").strip()
if token != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
def _adapter_for(model: str):
if model == "magpie":
return MagpieAdapter()
return OmniVoiceAdapter()
def _voice_config(payload: Dict[str, Any], temp_dir: Path) -> VoiceConfig:
data = dict(payload)
sample_b64 = data.pop("sample_b64", None)
voice = VoiceConfig.from_dict(data)
if sample_b64:
sample_path = temp_dir / "clone-sample.wav"
sample_path.write_bytes(base64.b64decode(sample_b64))
voice.sample_path = str(sample_path)
return voice
@app.function(image=image, gpu="any", timeout=900)
def render_preview(payload: Dict[str, Any]) -> Dict[str, Any]:
with tempfile.TemporaryDirectory() as tmp:
temp_dir = Path(tmp)
output_path = temp_dir / "preview.wav"
voice = _voice_config(payload["voice_config"], temp_dir)
result = _adapter_for(voice.model).synthesize(
text=str(payload["text"]),
output_path=output_path,
voice_config=voice,
diffusion_steps=int(payload.get("diffusion_steps", 32)),
speed=float(payload.get("speed", 1.0)),
)
return {
"audio_base64": base64.b64encode(output_path.read_bytes()).decode("ascii"),
"duration_seconds": result.get("duration_seconds", 0),
"sample_rate": result.get("sample_rate", 24000),
"backend": MODAL_BACKEND,
"model": voice.model,
}
def _job_state(job_id: str) -> Dict[str, Any]:
raw = jobs.get(
job_id,
{
"status": "pending",
"events": [],
"cancel_requested": False,
},
)
if raw is None:
return {
"status": "pending",
"events": [],
"cancel_requested": False,
}
if not isinstance(raw, dict):
raise RuntimeError(f"Corrupt job state for {job_id}: expected dict, got {type(raw).__name__}")
return dict(raw)
def _save_job_state(job_id: str, state: Dict[str, Any]) -> None:
jobs[job_id] = state
def _append_event(job_id: str, event: Dict[str, Any]) -> None:
state = _job_state(job_id)
state.setdefault("events", []).append(event)
state["status"] = event.get("type", state.get("status", "running"))
_save_job_state(job_id, state)
@app.function(image=image, gpu="any", timeout=60 * 60)
def render_book(job_id: str, payload: Dict[str, Any]) -> None:
with tempfile.TemporaryDirectory() as tmp:
temp_dir = Path(tmp)
voice = _voice_config(payload["voice_config"], temp_dir)
chapters = [chapter for chapter in payload["chapters"] if chapter.get("included", True)]
state = _job_state(job_id)
state["status"] = "running"
_save_job_state(job_id, state)
_append_event(
job_id,
{
"type": "started",
"session_id": payload["session_id"],
"total_chapters": len(chapters),
"book_title": payload["book"].get("title"),
"backend": MODAL_BACKEND,
"model": voice.model,
},
)
adapter = _adapter_for(voice.model)
outputs: List[str] = []
for index, chapter in enumerate(chapters):
state = _job_state(job_id)
if state.get("cancel_requested"):
_append_event(
job_id,
{
"type": "cancelled",
"session_id": payload["session_id"],
"backend": MODAL_BACKEND,
"model": voice.model,
},
)
return
chapter_id = str(chapter["id"])
_append_event(
job_id,
{
"type": "chapter_started",
"session_id": payload["session_id"],
"chapter_id": chapter_id,
"chapter_title": chapter["title"],
"chapter_index": index,
"overall_progress": index / max(1, len(chapters)),
"backend": MODAL_BACKEND,
"model": voice.model,
},
)
output_path = temp_dir / f"{index + 1:03d}-{chapter_id}.wav"
result = adapter.synthesize(
text=str(chapter["text"]),
output_path=output_path,
voice_config=voice,
diffusion_steps=int(payload.get("diffusion_steps", 32)),
speed=float(payload.get("speed", 1.0)),
)
filename = output_path.name
artifacts[f"{job_id}:{filename}"] = output_path.read_bytes()
outputs.append(filename)
_append_event(
job_id,
{
"type": "chapter_done",
"session_id": payload["session_id"],
"chapter_id": chapter_id,
"duration_seconds": int(result.get("duration_seconds", 0)),
"overall_progress": (index + 1) / max(1, len(chapters)),
"filename": filename,
"artifact_url": f"/artifacts/{job_id}/{filename}",
"backend": MODAL_BACKEND,
"model": voice.model,
},
)
_append_event(
job_id,
{
"type": "completed",
"session_id": payload["session_id"],
"outputs": outputs,
"backend": MODAL_BACKEND,
"model": voice.model,
},
)
@web_app.post("/preview")
def preview_endpoint(
payload: Dict[str, Any],
authorization: str | None = Header(default=None),
) -> Dict[str, Any]:
_check_auth(authorization)
return render_preview.remote(payload)
@web_app.post("/renders")
def submit_render_endpoint(
payload: Dict[str, Any],
authorization: str | None = Header(default=None),
) -> Dict[str, str]:
_check_auth(authorization)
job_id = str(uuid4())
_save_job_state(job_id, {"status": "pending", "events": [], "cancel_requested": False})
render_book.spawn(job_id, payload)
return {"job_id": job_id}
@web_app.get("/renders/{job_id}")
def render_status_endpoint(
job_id: str,
cursor: int = 0,
authorization: str | None = Header(default=None),
) -> Dict[str, Any]:
_check_auth(authorization)
try:
state = _job_state(job_id)
events = list(state.get("events", []))
return {
"job_id": job_id,
"status": state.get("status", "pending"),
"events": events[cursor:],
}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Unable to fetch render status for {job_id}: {exc}") from exc
@web_app.post("/renders/{job_id}/cancel")
def cancel_render_endpoint(
job_id: str,
authorization: str | None = Header(default=None),
) -> Dict[str, str]:
_check_auth(authorization)
state = _job_state(job_id)
state["cancel_requested"] = True
state["status"] = "cancelled"
_save_job_state(job_id, state)
return {"type": "cancelled", "job_id": job_id}
@web_app.get("/artifacts/{job_id}/{filename}")
def artifact_endpoint(
job_id: str,
filename: str,
authorization: str | None = Header(default=None),
):
_check_auth(authorization)
content = artifacts.get(f"{job_id}:{filename}")
if content is None:
raise HTTPException(status_code=404, detail="Artifact not found")
return Response(content=content, media_type="audio/wav")
@app.function(image=image)
@modal.asgi_app()
def fastapi_app():
return web_app