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