File size: 10,042 Bytes
af4851b 29f61a5 af4851b 4079a6f af4851b b46d0f8 af4851b cd0ff97 af4851b 29f61a5 af4851b cd0ff97 af4851b 4576e13 1f36bcf 4576e13 1f36bcf 4576e13 af4851b cd0ff97 af4851b cd0ff97 af4851b 4079a6f cd0ff97 af4851b cd0ff97 af4851b 13a101b af4851b cd0ff97 af4851b cd0ff97 af4851b cd0ff97 af4851b 29f61a5 cd0ff97 29f61a5 af4851b 186f4a4 af4851b 186f4a4 af4851b b46d0f8 af4851b 5b38545 af4851b 5b38545 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | import os
import json
import shutil
import socket
from pathlib import Path
from typing import Any, Dict, Generator, List, Optional
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from gradio import Blocks, Server
from gradio.events import api as gradio_api
from backend.config import (
APP_TITLE,
MAX_PREVIEW_CHARACTERS,
MAX_REFERENCE_AUDIO_SECONDS,
SESSION_TTL_SECONDS,
TEMP_ROOT,
)
from backend.epub import EpubConfig, parse_epub
from backend.export import export_audiobook
from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
from backend.session_store import SessionStore
from backend.synthesis_catalog import MAGPIE_OPTIONS, SYNTHESIS_BACKENDS, SYNTHESIS_MODELS
from backend.synthesis_service import SynthesisService
from backend.types import VoiceConfig
from backend.voice_design import VOICE_DESIGN_OPTIONS
from backend.voice_presets import VOICE_PRESETS
ROOT = Path(__file__).parent
FRONTEND_DIR = ROOT / "frontend"
TEMP_ROOT.mkdir(parents=True, exist_ok=True)
app = Server()
app.title = APP_TITLE
store = SessionStore(root=TEMP_ROOT, ttl_seconds=SESSION_TTL_SECONDS)
synthesis_service = SynthesisService(session_root=TEMP_ROOT)
def _warn_about_modal_configuration() -> None:
client = synthesis_service.modal_client
warning = client.configuration_warning()
if warning:
print(f"Modal configuration warning: {warning}")
return
if client.is_configured():
print(
"Modal backend configured: "
f"base_url={client.base_url} timeout={client.timeout_seconds}s poll_interval={client.poll_interval_seconds}s"
)
_warn_about_modal_configuration()
def _session_root(session_id: str) -> Path:
return store.ensure_session(session_id).root
def _book_payload(session_id: str) -> Dict[str, Any]:
return store.load_json(session_id, "book.json")
def _selected_book(session_id: str, selected_ids: List[str]) -> Dict[str, Any]:
book = _book_payload(session_id)
selected = set(selected_ids)
chapters = []
for chapter in book["chapters"]:
chapter = dict(chapter)
chapter["included"] = chapter["id"] in selected
chapters.append(chapter)
book["chapters"] = chapters
return book
def _voice_config_for_backend(voice_config: Dict[str, Any], session_id: str) -> Dict[str, Any]:
config = dict(voice_config)
sample_path = config.get("samplePath") or config.get("sample_path")
if sample_path:
sample_file = resolve_uploaded_path(sample_path)
target = _session_root(session_id) / "uploads" / sample_file.name
shutil.copyfile(sample_file, target)
config["samplePath"] = str(target)
return config
@app.api(name="parse_epub")
def parse_epub_api(session_id: str, epub_file: str) -> Dict[str, Any]:
store.cleanup_expired()
session = store.ensure_session(session_id)
source = resolve_uploaded_path(epub_file)
upload_name = resolve_uploaded_name(epub_file)
if not source.exists():
raise ValueError("Uploaded EPUB file was not found")
target = session.root / "uploads" / upload_name
shutil.copyfile(source, target)
payload = parse_epub(target, config=EpubConfig())
store.save_json(session_id, "book.json", payload)
return payload
@app.api(name="generate_preview")
def generate_preview_api(
session_id: str,
chapter_id: str,
voice_config: Dict[str, Any],
diffusion_steps: int = 32,
speed: float = 1.0,
) -> Dict[str, Any]:
book = _book_payload(session_id)
chapter = next((item for item in book["chapters"] if item["id"] == chapter_id), None)
if chapter is None:
raise ValueError("Chapter not found for preview")
text = str(chapter["text"])[:MAX_PREVIEW_CHARACTERS]
preview_path = _session_root(session_id) / "previews" / f"{chapter_id}.wav"
voice = _voice_config_for_backend(voice_config, session_id)
result = synthesis_service.generate_preview(
text=text,
output_path=preview_path,
voice_config=VoiceConfig.from_dict(voice),
diffusion_steps=diffusion_steps,
speed=speed,
)
return {
"url": f"/files/{session_id}/previews/{preview_path.name}",
"duration_seconds": result["duration_seconds"],
"backend": result["backend"],
"model": result["model"],
}
@app.api(name="start_render", concurrency_limit=1)
def start_render_api(
session_id: str,
selected_chapter_ids: List[str],
voice_config: Dict[str, Any],
diffusion_steps: int = 32,
speed: float = 1.0,
) -> Generator[Dict[str, Any], None, None]:
job = synthesis_service.get_job(session_id)
if job.status == "running":
raise ValueError("A render is already active for this session")
book = _selected_book(session_id, selected_chapter_ids)
voice = _voice_config_for_backend(voice_config, session_id)
captured: List[Dict[str, Any]] = []
for event in synthesis_service.render(
session_id=session_id,
book=book,
chapters=book["chapters"],
voice_config=voice,
diffusion_steps=diffusion_steps,
speed=speed,
):
if event.get("type") == "chapter_done" and event.get("output_path"):
filename = Path(str(event["output_path"])).name
event = {
**event,
"url": f"/files/{session_id}/renders/{filename}",
}
captured.append(event)
yield event
if captured and captured[-1]["type"] == "completed":
durations = {
event["chapter_id"]: event["duration_seconds"]
for event in captured
if event["type"] == "chapter_done"
}
manifest = {
"book": book,
"chapters": [
{
**chapter,
"duration_seconds": durations.get(chapter["id"], chapter.get("duration_seconds", 0)),
}
for chapter in book["chapters"]
if chapter.get("included")
],
"render_result": captured[-1],
}
store.save_json(session_id, "render_manifest.json", manifest)
@app.api(name="pause_render")
def pause_render_api(session_id: str) -> Dict[str, Any]:
return synthesis_service.pause(session_id)
@app.api(name="resume_render")
def resume_render_api(session_id: str) -> Dict[str, Any]:
return synthesis_service.resume(session_id)
@app.api(name="cancel_render")
def cancel_render_api(session_id: str) -> Dict[str, Any]:
return synthesis_service.cancel(session_id)
@app.api(name="export_audiobook")
def export_audiobook_api(
session_id: str,
format: str = "m4a",
metadata: Dict[str, str] | None = None,
embed_markers: bool = True,
) -> Dict[str, Any]:
manifest = store.load_json(session_id, "render_manifest.json")
export = export_audiobook(
session_root=_session_root(session_id),
chapters=manifest["chapters"],
output_format=format,
metadata=metadata or {},
embed_markers=embed_markers,
cover_path=None,
)
export["url"] = f"/files/{session_id}/exports/{Path(export['file']).name}"
return export
@app.get("/", response_class=HTMLResponse)
async def homepage() -> HTMLResponse:
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
preset_script = (
"<script>"
f"window.__VOICE_PRESETS__ = {json.dumps(VOICE_PRESETS)};"
f"window.__VOICE_DESIGN_OPTIONS__ = {json.dumps(VOICE_DESIGN_OPTIONS)};"
f"window.__MAGPIE_OPTIONS__ = {json.dumps(MAGPIE_OPTIONS)};"
f"window.__SYNTHESIS_MODELS__ = {json.dumps(SYNTHESIS_MODELS)};"
f"window.__SYNTHESIS_BACKENDS__ = {json.dumps(SYNTHESIS_BACKENDS)};"
"</script>"
)
return HTMLResponse(html.replace("</body>", f" {preset_script}\n </body>"))
@app.get("/health")
async def health() -> Dict[str, str]:
return {"status": "ok"}
@app.get("/files/{session_id}/{kind}/{filename}")
async def serve_generated_file(session_id: str, kind: str, filename: str, download: bool = False):
path = _session_root(session_id) / kind / filename
if not path.exists():
return JSONResponse({"error": "file not found"}, status_code=404)
if download:
return FileResponse(path, filename=filename)
return FileResponse(path, content_disposition_type="inline")
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="frontend-static")
def _build_demo() -> Blocks:
# Expose the launched Blocks at module scope so Gradio hot reload can find it.
with Blocks(mode="server") as demo:
for fn, api_kwargs in app._deferred_apis:
gradio_api(fn=fn, **api_kwargs)
return demo
demo = _build_demo()
def _port_is_available(port: int, host: str = "127.0.0.1") -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
return sock.connect_ex((host, port)) != 0
def _choose_server_port(preferred: int = 7860, attempts: int = 10) -> int:
env_port = os.getenv("GRADIO_SERVER_PORT")
if env_port:
return int(env_port)
for port in range(preferred, preferred + attempts):
if _port_is_available(port):
return port
raise OSError(
f"Cannot find empty port in range: {preferred}-{preferred + attempts - 1}. "
"Set GRADIO_SERVER_PORT to override."
)
def _launch_app(server_port: int) -> None:
demo.launch(
_app=app,
server_name="0.0.0.0",
server_port=server_port,
# This app serves its own frontend and uses Gradio for the API layer only.
# SSR adds a Node proxy that is unnecessary here and has been noisy in Spaces.
ssr_mode=False,
)
if __name__ == "__main__":
server_port = _choose_server_port()
print(f"Starting {APP_TITLE} on port {server_port}")
_launch_app(server_port)
|