Scriptorium / backend /synthesis_service.py
mattkevan's picture
fix: harden modal render status polling
3921761
Raw
History Blame Contribute Delete
6.28 kB
from pathlib import Path
from typing import Dict, Iterable, Optional
from backend.magpie_adapter import MagpieAdapter
from backend.modal_client import ModalSynthesisClient
from backend.omnivoice_adapter import OmniVoiceAdapter
from backend.render_pipeline import RenderPipeline
from backend.synthesis_catalog import LOCAL_BACKEND, MODAL_BACKEND, MAGPIE_MODEL, OMNIVOICE_MODEL
from backend.types import RenderJob, VoiceConfig
class SynthesisService:
def __init__(
self,
*,
session_root: Path,
local_synthesizers: Optional[Dict[str, object]] = None,
modal_client: Optional[ModalSynthesisClient] = None,
) -> None:
self.session_root = Path(session_root)
self.session_root.mkdir(parents=True, exist_ok=True)
self.local_synthesizers = local_synthesizers or {
OMNIVOICE_MODEL: OmniVoiceAdapter(),
MAGPIE_MODEL: MagpieAdapter(),
}
self.modal_client = modal_client or ModalSynthesisClient.from_env()
self.pipeline = RenderPipeline(session_root=self.session_root)
self._active_backends: Dict[str, str] = {}
self._modal_job_ids: Dict[str, str] = {}
self._modal_status: Dict[str, str] = {}
def get_job(self, session_id: str) -> RenderJob:
if self._active_backends.get(session_id) == MODAL_BACKEND:
return RenderJob(
session_id=session_id,
status=self._modal_status.get(session_id, "idle"),
)
return self.pipeline.get_job(session_id)
def generate_preview(
self,
*,
text: str,
output_path: Path,
voice_config: VoiceConfig,
diffusion_steps: int,
speed: float,
) -> Dict[str, object]:
if voice_config.backend == MODAL_BACKEND:
return self.modal_client.generate_preview(
text=text,
output_path=output_path,
voice_config=voice_config,
diffusion_steps=diffusion_steps,
speed=speed,
)
synthesizer = self._local_synthesizer(voice_config.model)
result = synthesizer.synthesize(
text=text,
output_path=output_path,
voice_config=voice_config,
diffusion_steps=diffusion_steps,
speed=speed,
)
result.setdefault("backend", LOCAL_BACKEND)
result.setdefault("model", voice_config.model)
return result
def render(
self,
*,
session_id: str,
book: Dict[str, object],
chapters: list[Dict[str, object]],
voice_config: Dict[str, object] | VoiceConfig,
diffusion_steps: int,
speed: float,
) -> Iterable[Dict[str, object]]:
voice = voice_config if isinstance(voice_config, VoiceConfig) else VoiceConfig.from_dict(voice_config)
self._active_backends[session_id] = voice.backend
if voice.backend == MODAL_BACKEND:
yield from self._render_modal(
session_id=session_id,
book=book,
chapters=chapters,
voice_config=voice,
diffusion_steps=diffusion_steps,
speed=speed,
)
return
synthesizer = self._local_synthesizer(voice.model)
yield from self.pipeline.render(
session_id=session_id,
book=book,
chapters=chapters,
voice_config=voice.to_dict(),
diffusion_steps=diffusion_steps,
speed=speed,
synthesizer=synthesizer,
)
def pause(self, session_id: str) -> Dict[str, object]:
if self._active_backends.get(session_id) == MODAL_BACKEND:
raise ValueError("Pause is only supported for local renders")
return self.pipeline.pause(session_id)
def resume(self, session_id: str) -> Dict[str, object]:
if self._active_backends.get(session_id) == MODAL_BACKEND:
raise ValueError("Resume is only supported for local renders")
return self.pipeline.resume(session_id)
def cancel(self, session_id: str) -> Dict[str, object]:
if self._active_backends.get(session_id) == MODAL_BACKEND:
job_id = self._modal_job_ids.get(session_id)
if not job_id:
return {"type": "cancelled", "session_id": session_id, "backend": MODAL_BACKEND}
self._modal_status[session_id] = "cancelled"
result = self.modal_client.cancel_render(job_id)
return {**result, "session_id": session_id}
self.pipeline.cancel(session_id)
return {"type": "cancelled", "session_id": session_id, "backend": LOCAL_BACKEND}
def _render_modal(
self,
*,
session_id: str,
book: Dict[str, object],
chapters: list[Dict[str, object]],
voice_config: VoiceConfig,
diffusion_steps: int,
speed: float,
) -> Iterable[Dict[str, object]]:
render_dir = self.session_root / session_id / "renders"
render_dir.mkdir(parents=True, exist_ok=True)
self._modal_status[session_id] = "pending"
job_id = self.modal_client.submit_render(
session_id=session_id,
book=book,
chapters=chapters,
voice_config=voice_config,
diffusion_steps=diffusion_steps,
speed=speed,
)
self._modal_job_ids[session_id] = job_id
self._modal_status[session_id] = "running"
for event in self.modal_client.render(
session_id=session_id,
job_id=job_id,
render_dir=render_dir,
voice_config=voice_config,
):
event_type = str(event.get("type", "running"))
if event_type not in {"log"}:
self._modal_status[session_id] = event_type
yield event
if self._modal_status.get(session_id) not in {"failed", "cancelled"}:
self._modal_status[session_id] = "completed"
def _local_synthesizer(self, model: str):
synthesizer = self.local_synthesizers.get(model)
if synthesizer is None:
raise ValueError(f"Unsupported synthesis model: {model}")
return synthesizer