mattkevan commited on
Commit
cd0ff97
·
1 Parent(s): 13a101b

add modal endpoint and magpietts

Browse files
app.py CHANGED
@@ -20,9 +20,9 @@ from backend.config import (
20
  from backend.epub import EpubConfig, parse_epub
21
  from backend.export import export_audiobook
22
  from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
23
- from backend.omnivoice_adapter import OmniVoiceAdapter
24
- from backend.render_pipeline import RenderPipeline
25
  from backend.session_store import SessionStore
 
 
26
  from backend.types import VoiceConfig
27
  from backend.voice_design import VOICE_DESIGN_OPTIONS
28
  from backend.voice_presets import VOICE_PRESETS
@@ -36,8 +36,7 @@ app = Server()
36
  app.title = APP_TITLE
37
 
38
  store = SessionStore(root=TEMP_ROOT, ttl_seconds=SESSION_TTL_SECONDS)
39
- synthesizer = OmniVoiceAdapter()
40
- pipeline = RenderPipeline(session_root=TEMP_ROOT, synthesizer=synthesizer)
41
 
42
 
43
  def _session_root(session_id: str) -> Path:
@@ -101,7 +100,7 @@ def generate_preview_api(
101
  text = str(chapter["text"])[:MAX_PREVIEW_CHARACTERS]
102
  preview_path = _session_root(session_id) / "previews" / f"{chapter_id}.wav"
103
  voice = _voice_config_for_backend(voice_config, session_id)
104
- result = synthesizer.synthesize(
105
  text=text,
106
  output_path=preview_path,
107
  voice_config=VoiceConfig.from_dict(voice),
@@ -112,6 +111,7 @@ def generate_preview_api(
112
  "url": f"/files/{session_id}/previews/{preview_path.name}",
113
  "duration_seconds": result["duration_seconds"],
114
  "backend": result["backend"],
 
115
  }
116
 
117
 
@@ -123,14 +123,14 @@ def start_render_api(
123
  diffusion_steps: int = 32,
124
  speed: float = 1.0,
125
  ) -> Generator[Dict[str, Any], None, None]:
126
- job = pipeline.get_job(session_id)
127
  if job.status == "running":
128
  raise ValueError("A render is already active for this session")
129
 
130
  book = _selected_book(session_id, selected_chapter_ids)
131
  voice = _voice_config_for_backend(voice_config, session_id)
132
  captured: List[Dict[str, Any]] = []
133
- for event in pipeline.render(
134
  session_id=session_id,
135
  book=book,
136
  chapters=book["chapters"],
@@ -170,18 +170,17 @@ def start_render_api(
170
 
171
  @app.api(name="pause_render")
172
  def pause_render_api(session_id: str) -> Dict[str, Any]:
173
- return pipeline.pause(session_id)
174
 
175
 
176
  @app.api(name="resume_render")
177
  def resume_render_api(session_id: str) -> Dict[str, Any]:
178
- return pipeline.resume(session_id)
179
 
180
 
181
  @app.api(name="cancel_render")
182
  def cancel_render_api(session_id: str) -> Dict[str, Any]:
183
- pipeline.cancel(session_id)
184
- return {"type": "cancelled", "session_id": session_id}
185
 
186
 
187
  @app.api(name="export_audiobook")
@@ -211,6 +210,9 @@ async def homepage() -> HTMLResponse:
211
  "<script>"
212
  f"window.__VOICE_PRESETS__ = {json.dumps(VOICE_PRESETS)};"
213
  f"window.__VOICE_DESIGN_OPTIONS__ = {json.dumps(VOICE_DESIGN_OPTIONS)};"
 
 
 
214
  "</script>"
215
  )
216
  return HTMLResponse(html.replace("</body>", f" {preset_script}\n </body>"))
 
20
  from backend.epub import EpubConfig, parse_epub
21
  from backend.export import export_audiobook
22
  from backend.input_files import resolve_uploaded_name, resolve_uploaded_path
 
 
23
  from backend.session_store import SessionStore
24
+ from backend.synthesis_catalog import MAGPIE_OPTIONS, SYNTHESIS_BACKENDS, SYNTHESIS_MODELS
25
+ from backend.synthesis_service import SynthesisService
26
  from backend.types import VoiceConfig
27
  from backend.voice_design import VOICE_DESIGN_OPTIONS
28
  from backend.voice_presets import VOICE_PRESETS
 
36
  app.title = APP_TITLE
37
 
38
  store = SessionStore(root=TEMP_ROOT, ttl_seconds=SESSION_TTL_SECONDS)
39
+ synthesis_service = SynthesisService(session_root=TEMP_ROOT)
 
40
 
41
 
42
  def _session_root(session_id: str) -> Path:
 
100
  text = str(chapter["text"])[:MAX_PREVIEW_CHARACTERS]
101
  preview_path = _session_root(session_id) / "previews" / f"{chapter_id}.wav"
102
  voice = _voice_config_for_backend(voice_config, session_id)
103
+ result = synthesis_service.generate_preview(
104
  text=text,
105
  output_path=preview_path,
106
  voice_config=VoiceConfig.from_dict(voice),
 
111
  "url": f"/files/{session_id}/previews/{preview_path.name}",
112
  "duration_seconds": result["duration_seconds"],
113
  "backend": result["backend"],
114
+ "model": result["model"],
115
  }
116
 
117
 
 
123
  diffusion_steps: int = 32,
124
  speed: float = 1.0,
125
  ) -> Generator[Dict[str, Any], None, None]:
126
+ job = synthesis_service.get_job(session_id)
127
  if job.status == "running":
128
  raise ValueError("A render is already active for this session")
129
 
130
  book = _selected_book(session_id, selected_chapter_ids)
131
  voice = _voice_config_for_backend(voice_config, session_id)
132
  captured: List[Dict[str, Any]] = []
133
+ for event in synthesis_service.render(
134
  session_id=session_id,
135
  book=book,
136
  chapters=book["chapters"],
 
170
 
171
  @app.api(name="pause_render")
172
  def pause_render_api(session_id: str) -> Dict[str, Any]:
173
+ return synthesis_service.pause(session_id)
174
 
175
 
176
  @app.api(name="resume_render")
177
  def resume_render_api(session_id: str) -> Dict[str, Any]:
178
+ return synthesis_service.resume(session_id)
179
 
180
 
181
  @app.api(name="cancel_render")
182
  def cancel_render_api(session_id: str) -> Dict[str, Any]:
183
+ return synthesis_service.cancel(session_id)
 
184
 
185
 
186
  @app.api(name="export_audiobook")
 
210
  "<script>"
211
  f"window.__VOICE_PRESETS__ = {json.dumps(VOICE_PRESETS)};"
212
  f"window.__VOICE_DESIGN_OPTIONS__ = {json.dumps(VOICE_DESIGN_OPTIONS)};"
213
+ f"window.__MAGPIE_OPTIONS__ = {json.dumps(MAGPIE_OPTIONS)};"
214
+ f"window.__SYNTHESIS_MODELS__ = {json.dumps(SYNTHESIS_MODELS)};"
215
+ f"window.__SYNTHESIS_BACKENDS__ = {json.dumps(SYNTHESIS_BACKENDS)};"
216
  "</script>"
217
  )
218
  return HTMLResponse(html.replace("</body>", f" {preset_script}\n </body>"))
backend/magpie_adapter.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import math
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Dict, Optional
6
+
7
+ import numpy as np
8
+ import soundfile as sf
9
+
10
+ from backend.synthesis_catalog import MAGPIE_LANGUAGES, MAGPIE_MODEL, MAGPIE_SPEAKERS
11
+ from backend.types import VoiceConfig
12
+
13
+ try:
14
+ import spaces
15
+ except ImportError:
16
+ class _SpacesShim:
17
+ @staticmethod
18
+ def GPU(fn=None, **_kwargs):
19
+ def decorate(inner):
20
+ return inner
21
+
22
+ if fn is not None:
23
+ return decorate(fn)
24
+ return decorate
25
+
26
+ spaces = _SpacesShim()
27
+
28
+
29
+ MAGPIE_SPEAKER_IDS = {
30
+ speaker["value"]: index
31
+ for index, speaker in enumerate(
32
+ sorted(MAGPIE_SPEAKERS, key=lambda item: ["John", "Sofia", "Aria", "Jason", "Leo"].index(item["value"]))
33
+ )
34
+ }
35
+ MAGPIE_SUPPORTED_LANGUAGES = {language["value"] for language in MAGPIE_LANGUAGES}
36
+
37
+
38
+ class MagpieAdapter:
39
+ def __init__(
40
+ self,
41
+ repo_id: str = "nvidia/magpie_tts_multilingual_357m",
42
+ checkpoint_filename: str = "magpie_tts_multilingual_357m.nemo",
43
+ codec_model_path: str = "nvidia/nemo-nano-codec-22khz-1.89kbps-21.5fps",
44
+ ) -> None:
45
+ self.repo_id = repo_id
46
+ self.checkpoint_filename = checkpoint_filename
47
+ self.codec_model_path = codec_model_path
48
+ self._model = None
49
+ self._engine = "fallback"
50
+
51
+ def _checkpoint_path(self) -> str:
52
+ from huggingface_hub import hf_hub_download
53
+
54
+ return hf_hub_download(
55
+ repo_id=self.repo_id,
56
+ filename=self.checkpoint_filename,
57
+ token=os.environ.get("HF_TOKEN"),
58
+ )
59
+
60
+ def _load_model(self):
61
+ if self._model is not None:
62
+ return self._model
63
+ try:
64
+ import torch
65
+ from nemo.collections.tts.modules.magpietts_inference.utils import (
66
+ ModelLoadConfig,
67
+ load_magpie_model,
68
+ )
69
+ except Exception:
70
+ self._engine = "fallback"
71
+ return None
72
+
73
+ config = ModelLoadConfig(
74
+ nemo_file=self._checkpoint_path(),
75
+ codecmodel_path=self.codec_model_path,
76
+ legacy_codebooks=False,
77
+ legacy_text_conditioning=False,
78
+ hparams_from_wandb=None,
79
+ )
80
+ model, _ = load_magpie_model(config)
81
+ model.eval()
82
+ if torch.cuda.is_available():
83
+ model.cuda()
84
+ self._model = model
85
+ self._engine = "magpie"
86
+ return self._model
87
+
88
+ @spaces.GPU(duration=300)
89
+ def synthesize(
90
+ self,
91
+ *,
92
+ text: str,
93
+ output_path: Path,
94
+ voice_config: VoiceConfig,
95
+ diffusion_steps: int,
96
+ speed: float,
97
+ language: Optional[str] = None,
98
+ ) -> Dict[str, object]:
99
+ del diffusion_steps, speed
100
+ output_path.parent.mkdir(parents=True, exist_ok=True)
101
+ speaker = voice_config.speaker or "Sofia"
102
+ if speaker not in MAGPIE_SPEAKER_IDS:
103
+ raise ValueError(f"Unsupported Magpie speaker: {speaker}")
104
+ target_language = voice_config.language or language or "en"
105
+ if target_language not in MAGPIE_SUPPORTED_LANGUAGES:
106
+ raise ValueError(f"Unsupported Magpie language: {target_language}")
107
+
108
+ model = self._load_model()
109
+ if model is None:
110
+ return self._synthesize_fallback(
111
+ text=text,
112
+ output_path=output_path,
113
+ speaker=speaker,
114
+ apply_text_normalization=voice_config.apply_text_normalization,
115
+ )
116
+
117
+ cleaned_text = text.strip()
118
+ if cleaned_text and cleaned_text[-1] not in ".?!":
119
+ cleaned_text = f"{cleaned_text}."
120
+ audio, audio_len = model.do_tts(
121
+ cleaned_text,
122
+ language=target_language,
123
+ apply_TN=voice_config.apply_text_normalization,
124
+ speaker_index=MAGPIE_SPEAKER_IDS[speaker],
125
+ )
126
+ waveform = audio[0, : audio_len[0]].detach().cpu().numpy().astype(np.float32)
127
+ sample_rate = int(getattr(model, "sample_rate", 22050))
128
+ sf.write(str(output_path), waveform, sample_rate)
129
+ duration_seconds = int(round(len(waveform) / sample_rate))
130
+ return {
131
+ "duration_seconds": max(1, duration_seconds),
132
+ "sample_rate": sample_rate,
133
+ "backend": "local",
134
+ "model": MAGPIE_MODEL,
135
+ "engine": self._engine,
136
+ }
137
+
138
+ def _synthesize_fallback(
139
+ self,
140
+ *,
141
+ text: str,
142
+ output_path: Path,
143
+ speaker: str,
144
+ apply_text_normalization: bool,
145
+ ) -> Dict[str, object]:
146
+ sample_rate = 22050
147
+ duration_seconds = max(1.0, min(20.0, len(text.split()) * 0.42))
148
+ total_samples = int(sample_rate * duration_seconds)
149
+ speaker_index = MAGPIE_SPEAKER_IDS.get(speaker, 0)
150
+ base_freq = 170.0 + (speaker_index * 28.0)
151
+ if apply_text_normalization:
152
+ base_freq += 8.0
153
+
154
+ timeline = np.linspace(0, duration_seconds, total_samples, endpoint=False)
155
+ waveform = (
156
+ 0.16 * np.sin(2 * math.pi * base_freq * timeline)
157
+ + 0.04 * np.sin(2 * math.pi * (base_freq * 1.5) * timeline)
158
+ ).astype(np.float32)
159
+ sf.write(str(output_path), waveform, sample_rate)
160
+ return {
161
+ "duration_seconds": int(round(duration_seconds)),
162
+ "sample_rate": sample_rate,
163
+ "backend": "local",
164
+ "model": MAGPIE_MODEL,
165
+ "engine": self._engine,
166
+ }
backend/modal_client.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Dict, Iterable, List, Optional
6
+
7
+ import requests
8
+
9
+ from backend.synthesis_catalog import MODAL_BACKEND
10
+ from backend.types import VoiceConfig
11
+
12
+
13
+ class ModalSynthesisClient:
14
+ def __init__(
15
+ self,
16
+ *,
17
+ base_url: Optional[str],
18
+ auth_token: Optional[str] = None,
19
+ timeout_seconds: float = 60.0,
20
+ poll_interval_seconds: float = 1.0,
21
+ ) -> None:
22
+ self.base_url = base_url.rstrip("/") if base_url else None
23
+ self.auth_token = auth_token
24
+ self.timeout_seconds = timeout_seconds
25
+ self.poll_interval_seconds = poll_interval_seconds
26
+
27
+ @classmethod
28
+ def from_env(cls) -> "ModalSynthesisClient":
29
+ return cls(
30
+ base_url=os.getenv("SCRIPTORIUM_MODAL_BASE_URL"),
31
+ auth_token=os.getenv("SCRIPTORIUM_MODAL_AUTH_TOKEN"),
32
+ timeout_seconds=float(os.getenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", "60")),
33
+ poll_interval_seconds=float(os.getenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", "1")),
34
+ )
35
+
36
+ def is_configured(self) -> bool:
37
+ return bool(self.base_url)
38
+
39
+ def generate_preview(
40
+ self,
41
+ *,
42
+ text: str,
43
+ output_path: Path,
44
+ voice_config: VoiceConfig,
45
+ diffusion_steps: int,
46
+ speed: float,
47
+ ) -> Dict[str, object]:
48
+ payload = {
49
+ "text": text,
50
+ "voice_config": voice_config.to_dict(),
51
+ "diffusion_steps": diffusion_steps,
52
+ "speed": speed,
53
+ }
54
+ data = self._post_json("/preview", payload)
55
+ output_path.parent.mkdir(parents=True, exist_ok=True)
56
+ output_path.write_bytes(base64.b64decode(data["audio_base64"]))
57
+ return {
58
+ "duration_seconds": int(data.get("duration_seconds", 0)),
59
+ "sample_rate": int(data.get("sample_rate", 24000)),
60
+ "backend": MODAL_BACKEND,
61
+ "model": voice_config.model,
62
+ }
63
+
64
+ def submit_render(
65
+ self,
66
+ *,
67
+ session_id: str,
68
+ book: Dict[str, object],
69
+ chapters: List[Dict[str, object]],
70
+ voice_config: VoiceConfig,
71
+ diffusion_steps: int,
72
+ speed: float,
73
+ ) -> str:
74
+ payload = {
75
+ "session_id": session_id,
76
+ "book": book,
77
+ "chapters": chapters,
78
+ "voice_config": voice_config.to_dict(),
79
+ "diffusion_steps": diffusion_steps,
80
+ "speed": speed,
81
+ }
82
+ data = self._post_json("/renders", payload)
83
+ job_id = data.get("job_id")
84
+ if not job_id:
85
+ raise ValueError("Modal render submission did not return a job id")
86
+ return str(job_id)
87
+
88
+ def render(
89
+ self,
90
+ *,
91
+ session_id: str,
92
+ job_id: str,
93
+ render_dir: Path,
94
+ voice_config: VoiceConfig,
95
+ ) -> Iterable[Dict[str, object]]:
96
+ cursor = 0
97
+ while True:
98
+ data = self._get_json(f"/renders/{job_id}", params={"cursor": cursor})
99
+ events = list(data.get("events") or [])
100
+ cursor += len(events)
101
+ for event in events:
102
+ yield self._materialize_event(
103
+ session_id=session_id,
104
+ render_dir=render_dir,
105
+ voice_config=voice_config,
106
+ event=event,
107
+ )
108
+ status = str(data.get("status", "pending"))
109
+ if status in {"completed", "failed", "cancelled"}:
110
+ break
111
+ time.sleep(self.poll_interval_seconds)
112
+
113
+ def cancel_render(self, job_id: str) -> Dict[str, object]:
114
+ data = self._post_json(f"/renders/{job_id}/cancel", {})
115
+ return {
116
+ "type": data.get("type", "cancelled"),
117
+ "job_id": job_id,
118
+ "backend": MODAL_BACKEND,
119
+ }
120
+
121
+ def _materialize_event(
122
+ self,
123
+ *,
124
+ session_id: str,
125
+ render_dir: Path,
126
+ voice_config: VoiceConfig,
127
+ event: Dict[str, object],
128
+ ) -> Dict[str, object]:
129
+ materialized = dict(event)
130
+ materialized.setdefault("session_id", session_id)
131
+ materialized.setdefault("backend", MODAL_BACKEND)
132
+ materialized.setdefault("model", voice_config.model)
133
+ artifact_url = materialized.get("artifact_url")
134
+ filename = materialized.get("filename")
135
+ if artifact_url and filename:
136
+ output_path = render_dir / str(filename)
137
+ output_path.parent.mkdir(parents=True, exist_ok=True)
138
+ response = requests.get(
139
+ self._absolute_url(str(artifact_url)),
140
+ headers=self._headers(),
141
+ timeout=self.timeout_seconds,
142
+ )
143
+ response.raise_for_status()
144
+ output_path.write_bytes(response.content)
145
+ materialized["output_path"] = str(output_path)
146
+ return materialized
147
+
148
+ def _post_json(self, path: str, payload: Dict[str, object]) -> Dict[str, object]:
149
+ self._ensure_configured()
150
+ response = requests.post(
151
+ self._absolute_url(path),
152
+ json=payload,
153
+ headers=self._headers(),
154
+ timeout=self.timeout_seconds,
155
+ )
156
+ response.raise_for_status()
157
+ return response.json()
158
+
159
+ def _get_json(self, path: str, *, params: Dict[str, object]) -> Dict[str, object]:
160
+ self._ensure_configured()
161
+ response = requests.get(
162
+ self._absolute_url(path),
163
+ params=params,
164
+ headers=self._headers(),
165
+ timeout=self.timeout_seconds,
166
+ )
167
+ response.raise_for_status()
168
+ return response.json()
169
+
170
+ def _absolute_url(self, path: str) -> str:
171
+ if path.startswith("http://") or path.startswith("https://"):
172
+ return path
173
+ return f"{self.base_url}{path}"
174
+
175
+ def _headers(self) -> Dict[str, str]:
176
+ headers = {"Accept": "application/json"}
177
+ if self.auth_token:
178
+ headers["Authorization"] = f"Bearer {self.auth_token}"
179
+ return headers
180
+
181
+ def _ensure_configured(self) -> None:
182
+ if not self.base_url:
183
+ raise ValueError("Modal backend is not configured. Set SCRIPTORIUM_MODAL_BASE_URL.")
backend/omnivoice_adapter.py CHANGED
@@ -35,6 +35,7 @@ class OmniVoiceAdapter:
35
  self.dtype_name = dtype_name
36
  self._model = None
37
  self._backend = "fallback"
 
38
 
39
  def _load_model(self):
40
  if self._model is not None:
@@ -76,18 +77,22 @@ class OmniVoiceAdapter:
76
  speed=speed,
77
  )
78
 
 
 
79
  kwargs = {
80
  "text": text,
81
- "num_step": diffusion_steps,
82
  "speed": speed,
 
 
 
 
 
83
  }
84
  if language:
85
  kwargs["language"] = language
86
 
87
  if voice_config.mode == "clone":
88
- kwargs["ref_audio"] = voice_config.sample_path
89
- if voice_config.reference_text:
90
- kwargs["ref_text"] = voice_config.reference_text
91
  elif voice_config.mode == "design":
92
  kwargs["instruct"] = voice_config.design_prompt
93
  elif voice_config.narrator_id:
@@ -103,9 +108,26 @@ class OmniVoiceAdapter:
103
  return {
104
  "duration_seconds": max(1, duration_seconds),
105
  "sample_rate": sample_rate,
106
- "backend": self._backend,
 
 
107
  }
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def _synthesize_fallback(
110
  self,
111
  *,
@@ -136,5 +158,7 @@ class OmniVoiceAdapter:
136
  return {
137
  "duration_seconds": int(round(duration_seconds)),
138
  "sample_rate": sample_rate,
139
- "backend": self._backend,
 
 
140
  }
 
35
  self.dtype_name = dtype_name
36
  self._model = None
37
  self._backend = "fallback"
38
+ self._voice_clone_prompt_cache = {}
39
 
40
  def _load_model(self):
41
  if self._model is not None:
 
77
  speed=speed,
78
  )
79
 
80
+ from omnivoice.models.omnivoice import OmniVoiceGenerationConfig
81
+
82
  kwargs = {
83
  "text": text,
 
84
  "speed": speed,
85
+ "generation_config": OmniVoiceGenerationConfig(
86
+ num_step=diffusion_steps,
87
+ position_temperature=0.0,
88
+ class_temperature=0.0,
89
+ ),
90
  }
91
  if language:
92
  kwargs["language"] = language
93
 
94
  if voice_config.mode == "clone":
95
+ kwargs["voice_clone_prompt"] = self._voice_clone_prompt(voice_config, model)
 
 
96
  elif voice_config.mode == "design":
97
  kwargs["instruct"] = voice_config.design_prompt
98
  elif voice_config.narrator_id:
 
108
  return {
109
  "duration_seconds": max(1, duration_seconds),
110
  "sample_rate": sample_rate,
111
+ "backend": "local",
112
+ "model": "omnivoice",
113
+ "engine": self._backend,
114
  }
115
 
116
+ def _voice_clone_prompt(self, voice_config: VoiceConfig, model):
117
+ cache_key = (
118
+ voice_config.sample_path or "",
119
+ voice_config.reference_text or "",
120
+ )
121
+ prompt = self._voice_clone_prompt_cache.get(cache_key)
122
+ if prompt is not None:
123
+ return prompt
124
+ prompt = model.create_voice_clone_prompt(
125
+ ref_audio=voice_config.sample_path,
126
+ ref_text=voice_config.reference_text,
127
+ )
128
+ self._voice_clone_prompt_cache[cache_key] = prompt
129
+ return prompt
130
+
131
  def _synthesize_fallback(
132
  self,
133
  *,
 
158
  return {
159
  "duration_seconds": int(round(duration_seconds)),
160
  "sample_rate": sample_rate,
161
+ "backend": "local",
162
+ "model": "omnivoice",
163
+ "engine": self._backend,
164
  }
backend/render_pipeline.py CHANGED
@@ -5,7 +5,7 @@ from backend.types import RenderArtifact, RenderJob, VoiceConfig
5
 
6
 
7
  class RenderPipeline:
8
- def __init__(self, session_root: Path, synthesizer) -> None:
9
  self.session_root = Path(session_root)
10
  self.session_root.mkdir(parents=True, exist_ok=True)
11
  self.synthesizer = synthesizer
@@ -39,17 +39,23 @@ class RenderPipeline:
39
  voice_config: Dict[str, object],
40
  diffusion_steps: int,
41
  speed: float,
 
42
  ) -> Iterable[Dict[str, object]]:
43
  selected = [chapter for chapter in chapters if chapter.get("included", True)]
44
  job = self.get_job(session_id)
45
  job.status = "running"
46
  job.outputs.clear()
 
 
 
47
 
48
  yield {
49
  "type": "started",
50
  "session_id": session_id,
51
  "total_chapters": len(selected),
52
  "book_title": book.get("title"),
 
 
53
  }
54
 
55
  voice = VoiceConfig.from_dict(voice_config)
@@ -58,7 +64,7 @@ class RenderPipeline:
58
 
59
  for index, chapter in enumerate(selected):
60
  if session_id in self._cancelled:
61
- yield {"type": "cancelled", "session_id": session_id}
62
  self._cancelled.discard(session_id)
63
  job.status = "cancelled"
64
  return
@@ -72,10 +78,12 @@ class RenderPipeline:
72
  "chapter_title": chapter["title"],
73
  "chapter_index": index,
74
  "overall_progress": index / max(1, len(selected)),
 
 
75
  }
76
 
77
  output_path = render_dir / f"{index + 1:03d}-{chapter_id}.wav"
78
- result = self.synthesizer.synthesize(
79
  text=str(chapter["text"]),
80
  output_path=output_path,
81
  voice_config=voice,
@@ -88,6 +96,8 @@ class RenderPipeline:
88
  "session_id": session_id,
89
  "chapter_id": chapter_id,
90
  "percent": 100,
 
 
91
  }
92
 
93
  artifact = RenderArtifact(
@@ -103,6 +113,8 @@ class RenderPipeline:
103
  "duration_seconds": artifact.duration_seconds,
104
  "overall_progress": (index + 1) / max(1, len(selected)),
105
  "output_path": str(output_path),
 
 
106
  }
107
 
108
  job.status = "completed"
@@ -110,4 +122,6 @@ class RenderPipeline:
110
  "type": "completed",
111
  "session_id": session_id,
112
  "outputs": [str(artifact.path) for artifact in job.outputs],
 
 
113
  }
 
5
 
6
 
7
  class RenderPipeline:
8
+ def __init__(self, session_root: Path, synthesizer=None) -> None:
9
  self.session_root = Path(session_root)
10
  self.session_root.mkdir(parents=True, exist_ok=True)
11
  self.synthesizer = synthesizer
 
39
  voice_config: Dict[str, object],
40
  diffusion_steps: int,
41
  speed: float,
42
+ synthesizer=None,
43
  ) -> Iterable[Dict[str, object]]:
44
  selected = [chapter for chapter in chapters if chapter.get("included", True)]
45
  job = self.get_job(session_id)
46
  job.status = "running"
47
  job.outputs.clear()
48
+ synth = synthesizer or self.synthesizer
49
+ if synth is None:
50
+ raise ValueError("No synthesizer provided for render pipeline")
51
 
52
  yield {
53
  "type": "started",
54
  "session_id": session_id,
55
  "total_chapters": len(selected),
56
  "book_title": book.get("title"),
57
+ "backend": "local",
58
+ "model": VoiceConfig.from_dict(voice_config).model,
59
  }
60
 
61
  voice = VoiceConfig.from_dict(voice_config)
 
64
 
65
  for index, chapter in enumerate(selected):
66
  if session_id in self._cancelled:
67
+ yield {"type": "cancelled", "session_id": session_id, "backend": "local", "model": voice.model}
68
  self._cancelled.discard(session_id)
69
  job.status = "cancelled"
70
  return
 
78
  "chapter_title": chapter["title"],
79
  "chapter_index": index,
80
  "overall_progress": index / max(1, len(selected)),
81
+ "backend": "local",
82
+ "model": voice.model,
83
  }
84
 
85
  output_path = render_dir / f"{index + 1:03d}-{chapter_id}.wav"
86
+ result = synth.synthesize(
87
  text=str(chapter["text"]),
88
  output_path=output_path,
89
  voice_config=voice,
 
96
  "session_id": session_id,
97
  "chapter_id": chapter_id,
98
  "percent": 100,
99
+ "backend": result.get("backend", "local"),
100
+ "model": result.get("model", voice.model),
101
  }
102
 
103
  artifact = RenderArtifact(
 
113
  "duration_seconds": artifact.duration_seconds,
114
  "overall_progress": (index + 1) / max(1, len(selected)),
115
  "output_path": str(output_path),
116
+ "backend": result.get("backend", "local"),
117
+ "model": result.get("model", voice.model),
118
  }
119
 
120
  job.status = "completed"
 
122
  "type": "completed",
123
  "session_id": session_id,
124
  "outputs": [str(artifact.path) for artifact in job.outputs],
125
+ "backend": "local",
126
+ "model": voice.model,
127
  }
backend/synthesis_catalog.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ OMNIVOICE_MODEL = "omnivoice"
2
+ MAGPIE_MODEL = "magpie"
3
+
4
+ LOCAL_BACKEND = "local"
5
+ MODAL_BACKEND = "modal"
6
+
7
+ SYNTHESIS_MODELS = [
8
+ {
9
+ "id": OMNIVOICE_MODEL,
10
+ "name": "OmniVoice",
11
+ "desc": "Presets, design prompts, and voice cloning",
12
+ },
13
+ {
14
+ "id": MAGPIE_MODEL,
15
+ "name": "Magpie TTS",
16
+ "desc": "Multilingual speaker presets with text normalization",
17
+ },
18
+ ]
19
+
20
+ SYNTHESIS_BACKENDS = [
21
+ {
22
+ "id": LOCAL_BACKEND,
23
+ "name": "HF/local",
24
+ "desc": "Run synthesis inside this Space runtime",
25
+ },
26
+ {
27
+ "id": MODAL_BACKEND,
28
+ "name": "Modal",
29
+ "desc": "Offload synthesis to a deployed Modal worker",
30
+ },
31
+ ]
32
+
33
+ MAGPIE_SPEAKERS = [
34
+ {"value": "Sofia", "label": "Sofia"},
35
+ {"value": "Aria", "label": "Aria"},
36
+ {"value": "Jason", "label": "Jason"},
37
+ {"value": "Leo", "label": "Leo"},
38
+ {"value": "John", "label": "John"},
39
+ ]
40
+
41
+ MAGPIE_LANGUAGES = [
42
+ {"value": "en", "label": "English"},
43
+ {"value": "de", "label": "German"},
44
+ {"value": "es", "label": "Spanish"},
45
+ {"value": "fr", "label": "French"},
46
+ {"value": "it", "label": "Italian"},
47
+ {"value": "vi", "label": "Vietnamese"},
48
+ {"value": "zh", "label": "Mandarin Chinese"},
49
+ {"value": "hi", "label": "Hindi"},
50
+ {"value": "ja", "label": "Japanese"},
51
+ ]
52
+
53
+ MAGPIE_OPTIONS = {
54
+ "speakers": MAGPIE_SPEAKERS,
55
+ "languages": MAGPIE_LANGUAGES,
56
+ }
backend/synthesis_service.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Dict, Iterable, Optional
3
+
4
+ from backend.magpie_adapter import MagpieAdapter
5
+ from backend.modal_client import ModalSynthesisClient
6
+ from backend.omnivoice_adapter import OmniVoiceAdapter
7
+ from backend.render_pipeline import RenderPipeline
8
+ from backend.synthesis_catalog import LOCAL_BACKEND, MODAL_BACKEND, MAGPIE_MODEL, OMNIVOICE_MODEL
9
+ from backend.types import RenderJob, VoiceConfig
10
+
11
+
12
+ class SynthesisService:
13
+ def __init__(
14
+ self,
15
+ *,
16
+ session_root: Path,
17
+ local_synthesizers: Optional[Dict[str, object]] = None,
18
+ modal_client: Optional[ModalSynthesisClient] = None,
19
+ ) -> None:
20
+ self.session_root = Path(session_root)
21
+ self.session_root.mkdir(parents=True, exist_ok=True)
22
+ self.local_synthesizers = local_synthesizers or {
23
+ OMNIVOICE_MODEL: OmniVoiceAdapter(),
24
+ MAGPIE_MODEL: MagpieAdapter(),
25
+ }
26
+ self.modal_client = modal_client or ModalSynthesisClient.from_env()
27
+ self.pipeline = RenderPipeline(session_root=self.session_root)
28
+ self._active_backends: Dict[str, str] = {}
29
+ self._modal_job_ids: Dict[str, str] = {}
30
+ self._modal_status: Dict[str, str] = {}
31
+
32
+ def get_job(self, session_id: str) -> RenderJob:
33
+ if self._active_backends.get(session_id) == MODAL_BACKEND:
34
+ return RenderJob(
35
+ session_id=session_id,
36
+ status=self._modal_status.get(session_id, "idle"),
37
+ )
38
+ return self.pipeline.get_job(session_id)
39
+
40
+ def generate_preview(
41
+ self,
42
+ *,
43
+ text: str,
44
+ output_path: Path,
45
+ voice_config: VoiceConfig,
46
+ diffusion_steps: int,
47
+ speed: float,
48
+ ) -> Dict[str, object]:
49
+ if voice_config.backend == MODAL_BACKEND:
50
+ return self.modal_client.generate_preview(
51
+ text=text,
52
+ output_path=output_path,
53
+ voice_config=voice_config,
54
+ diffusion_steps=diffusion_steps,
55
+ speed=speed,
56
+ )
57
+ synthesizer = self._local_synthesizer(voice_config.model)
58
+ result = synthesizer.synthesize(
59
+ text=text,
60
+ output_path=output_path,
61
+ voice_config=voice_config,
62
+ diffusion_steps=diffusion_steps,
63
+ speed=speed,
64
+ )
65
+ result.setdefault("backend", LOCAL_BACKEND)
66
+ result.setdefault("model", voice_config.model)
67
+ return result
68
+
69
+ def render(
70
+ self,
71
+ *,
72
+ session_id: str,
73
+ book: Dict[str, object],
74
+ chapters: list[Dict[str, object]],
75
+ voice_config: Dict[str, object] | VoiceConfig,
76
+ diffusion_steps: int,
77
+ speed: float,
78
+ ) -> Iterable[Dict[str, object]]:
79
+ voice = voice_config if isinstance(voice_config, VoiceConfig) else VoiceConfig.from_dict(voice_config)
80
+ self._active_backends[session_id] = voice.backend
81
+ if voice.backend == MODAL_BACKEND:
82
+ yield from self._render_modal(
83
+ session_id=session_id,
84
+ book=book,
85
+ chapters=chapters,
86
+ voice_config=voice,
87
+ diffusion_steps=diffusion_steps,
88
+ speed=speed,
89
+ )
90
+ return
91
+
92
+ synthesizer = self._local_synthesizer(voice.model)
93
+ yield from self.pipeline.render(
94
+ session_id=session_id,
95
+ book=book,
96
+ chapters=chapters,
97
+ voice_config=voice.to_dict(),
98
+ diffusion_steps=diffusion_steps,
99
+ speed=speed,
100
+ synthesizer=synthesizer,
101
+ )
102
+
103
+ def pause(self, session_id: str) -> Dict[str, object]:
104
+ if self._active_backends.get(session_id) == MODAL_BACKEND:
105
+ raise ValueError("Pause is only supported for local renders")
106
+ return self.pipeline.pause(session_id)
107
+
108
+ def resume(self, session_id: str) -> Dict[str, object]:
109
+ if self._active_backends.get(session_id) == MODAL_BACKEND:
110
+ raise ValueError("Resume is only supported for local renders")
111
+ return self.pipeline.resume(session_id)
112
+
113
+ def cancel(self, session_id: str) -> Dict[str, object]:
114
+ if self._active_backends.get(session_id) == MODAL_BACKEND:
115
+ job_id = self._modal_job_ids.get(session_id)
116
+ if not job_id:
117
+ return {"type": "cancelled", "session_id": session_id, "backend": MODAL_BACKEND}
118
+ self._modal_status[session_id] = "cancelled"
119
+ result = self.modal_client.cancel_render(job_id)
120
+ return {**result, "session_id": session_id}
121
+ self.pipeline.cancel(session_id)
122
+ return {"type": "cancelled", "session_id": session_id, "backend": LOCAL_BACKEND}
123
+
124
+ def _render_modal(
125
+ self,
126
+ *,
127
+ session_id: str,
128
+ book: Dict[str, object],
129
+ chapters: list[Dict[str, object]],
130
+ voice_config: VoiceConfig,
131
+ diffusion_steps: int,
132
+ speed: float,
133
+ ) -> Iterable[Dict[str, object]]:
134
+ render_dir = self.session_root / session_id / "renders"
135
+ render_dir.mkdir(parents=True, exist_ok=True)
136
+ self._modal_status[session_id] = "pending"
137
+ job_id = self.modal_client.submit_render(
138
+ session_id=session_id,
139
+ book=book,
140
+ chapters=chapters,
141
+ voice_config=voice_config,
142
+ diffusion_steps=diffusion_steps,
143
+ speed=speed,
144
+ )
145
+ self._modal_job_ids[session_id] = job_id
146
+ self._modal_status[session_id] = "running"
147
+ for event in self.modal_client.render(
148
+ session_id=session_id,
149
+ job_id=job_id,
150
+ render_dir=render_dir,
151
+ voice_config=voice_config,
152
+ ):
153
+ self._modal_status[session_id] = str(event.get("type", "running"))
154
+ yield event
155
+ if self._modal_status.get(session_id) not in {"failed", "cancelled"}:
156
+ self._modal_status[session_id] = "completed"
157
+
158
+ def _local_synthesizer(self, model: str):
159
+ synthesizer = self.local_synthesizers.get(model)
160
+ if synthesizer is None:
161
+ raise ValueError(f"Unsupported synthesis model: {model}")
162
+ return synthesizer
backend/types.py CHANGED
@@ -2,6 +2,8 @@ from dataclasses import asdict, dataclass, field
2
  from pathlib import Path
3
  from typing import Any, Dict, List, Optional
4
 
 
 
5
 
6
  JsonDict = Dict[str, Any]
7
 
@@ -33,22 +35,48 @@ class SessionRecord:
33
 
34
  @dataclass
35
  class VoiceConfig:
36
- mode: str
 
 
37
  narrator_id: Optional[str] = None
38
  sample_path: Optional[str] = None
39
  reference_text: Optional[str] = None
40
  design_prompt: Optional[str] = None
 
 
 
41
 
42
  @classmethod
43
  def from_dict(cls, data: JsonDict) -> "VoiceConfig":
44
  return cls(
45
  mode=data.get("mode", "auto"),
 
 
46
  narrator_id=data.get("narratorId") or data.get("narrator_id"),
47
  sample_path=data.get("samplePath") or data.get("sample_path"),
48
  reference_text=data.get("referenceText") or data.get("reference_text"),
49
  design_prompt=data.get("designPrompt") or data.get("design_prompt"),
 
 
 
 
 
50
  )
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  @dataclass
54
  class RenderArtifact:
@@ -63,4 +91,3 @@ class RenderJob:
63
  status: str = "idle"
64
  current_chapter_id: Optional[str] = None
65
  outputs: List[RenderArtifact] = field(default_factory=list)
66
-
 
2
  from pathlib import Path
3
  from typing import Any, Dict, List, Optional
4
 
5
+ from backend.synthesis_catalog import LOCAL_BACKEND, OMNIVOICE_MODEL
6
+
7
 
8
  JsonDict = Dict[str, Any]
9
 
 
35
 
36
  @dataclass
37
  class VoiceConfig:
38
+ mode: str = "auto"
39
+ model: str = OMNIVOICE_MODEL
40
+ backend: str = LOCAL_BACKEND
41
  narrator_id: Optional[str] = None
42
  sample_path: Optional[str] = None
43
  reference_text: Optional[str] = None
44
  design_prompt: Optional[str] = None
45
+ speaker: Optional[str] = None
46
+ language: Optional[str] = None
47
+ apply_text_normalization: bool = False
48
 
49
  @classmethod
50
  def from_dict(cls, data: JsonDict) -> "VoiceConfig":
51
  return cls(
52
  mode=data.get("mode", "auto"),
53
+ model=data.get("model", OMNIVOICE_MODEL),
54
+ backend=data.get("backend", LOCAL_BACKEND),
55
  narrator_id=data.get("narratorId") or data.get("narrator_id"),
56
  sample_path=data.get("samplePath") or data.get("sample_path"),
57
  reference_text=data.get("referenceText") or data.get("reference_text"),
58
  design_prompt=data.get("designPrompt") or data.get("design_prompt"),
59
+ speaker=data.get("speaker"),
60
+ language=data.get("language"),
61
+ apply_text_normalization=bool(
62
+ data.get("applyTextNormalization", data.get("apply_text_normalization", False))
63
+ ),
64
  )
65
 
66
+ def to_dict(self) -> JsonDict:
67
+ return {
68
+ "mode": self.mode,
69
+ "model": self.model,
70
+ "backend": self.backend,
71
+ "narratorId": self.narrator_id,
72
+ "samplePath": self.sample_path,
73
+ "referenceText": self.reference_text,
74
+ "designPrompt": self.design_prompt,
75
+ "speaker": self.speaker,
76
+ "language": self.language,
77
+ "applyTextNormalization": self.apply_text_normalization,
78
+ }
79
+
80
 
81
  @dataclass
82
  class RenderArtifact:
 
91
  status: str = "idle"
92
  current_chapter_id: Optional[str] = None
93
  outputs: List[RenderArtifact] = field(default_factory=list)
 
frontend/app.js CHANGED
@@ -2,6 +2,26 @@ import { Client, handle_file } from "https://esm.sh/@gradio/client";
2
 
3
  const NARRATORS = globalThis.__VOICE_PRESETS__ || [];
4
  const VOICE_DESIGN_OPTIONS = globalThis.__VOICE_DESIGN_OPTIONS__ || [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  const DEFAULT_NARRATOR = NARRATORS[0] || {
6
  id: "the-archivist",
7
  name: "The Archivist",
@@ -19,11 +39,16 @@ const state = {
19
  book: null,
20
  chapters: [],
21
  currentChapterId: null,
 
 
22
  voiceMode: "auto",
23
  narratorId: DEFAULT_NARRATOR.id,
24
  cloneConsent: false,
25
  cloneSampleFile: null,
26
  cloneReferenceText: "",
 
 
 
27
  designSelections: {
28
  gender: "",
29
  age: "",
@@ -37,8 +62,12 @@ const state = {
37
  errorMessage: "",
38
  renderStatus: null,
39
  renderEvents: [],
 
40
  renderResult: null,
41
  renderSubmission: null,
 
 
 
42
  previewUrl: "",
43
  exportFormat: "m4a",
44
  embedMarkers: true,
@@ -119,6 +148,22 @@ function handleChange(event) {
119
  if (target.matches("[data-design-field]")) {
120
  state.designSelections[target.dataset.designField] = target.value;
121
  render();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
  }
124
 
@@ -154,15 +199,21 @@ const actionHandlers = {
154
  syncNarratorMetadata();
155
  render();
156
  },
 
 
 
 
 
 
 
 
 
 
 
 
157
  setVoiceMode(target) {
158
  state.voiceMode = target.dataset.mode;
159
- if (state.voiceMode === "auto") {
160
- syncNarratorMetadata();
161
- } else if (state.voiceMode === "clone") {
162
- state.exportMetadata.narrator = "Cloned Narrator (OmniVoice)";
163
- } else {
164
- state.exportMetadata.narrator = "Designed Narrator (OmniVoice)";
165
- }
166
  render();
167
  },
168
  focusChapter(target) {
@@ -196,6 +247,10 @@ const actionHandlers = {
196
  render();
197
  },
198
  async exportBook() {
 
 
 
 
199
  await requestExport();
200
  },
201
  goConfigure() {
@@ -219,15 +274,36 @@ const actionHandlers = {
219
  document.getElementById("clone-audio")?.click();
220
  },
221
  playExportPreview(target) {
222
- const audio = document.getElementById("preview-audio");
223
  if (!audio) return;
 
 
 
 
224
  if (audio.paused) {
225
  audio.play();
226
- target.textContent = "❚❚";
227
  } else {
228
  audio.pause();
229
- target.textContent = "▶";
230
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  },
232
  };
233
 
@@ -281,7 +357,10 @@ async function submitRender() {
281
  state.statusMessage = "Submitting audiobook render…";
282
  state.step = "generate";
283
  state.renderEvents = [];
 
284
  state.renderResult = null;
 
 
285
  render();
286
 
287
  const submission = state.client.submit("/start_render", {
@@ -317,6 +396,8 @@ async function submitRender() {
317
  }
318
 
319
  function updateRenderState(payload) {
 
 
320
  switch (payload.type) {
321
  case "started":
322
  state.statusMessage = `Binding ${payload.total_chapters} chapters…`;
@@ -364,6 +445,7 @@ async function requestExport() {
364
  embed_markers: state.embedMarkers,
365
  });
366
  state.exportFile = payload;
 
367
  state.previewUrl = payload.url || state.previewUrl;
368
  state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
369
  } catch (error) {
@@ -379,8 +461,20 @@ async function callPredict(apiName, payload) {
379
  }
380
 
381
  function buildVoiceConfig() {
 
 
 
 
 
 
 
 
 
 
382
  if (state.voiceMode === "clone") {
383
  return {
 
 
384
  mode: "clone",
385
  referenceText: state.cloneReferenceText,
386
  cloneConsent: state.cloneConsent,
@@ -389,11 +483,15 @@ function buildVoiceConfig() {
389
  }
390
  if (state.voiceMode === "design") {
391
  return {
 
 
392
  mode: "design",
393
  designPrompt: buildDesignPrompt(),
394
  };
395
  }
396
  return {
 
 
397
  mode: "auto",
398
  narratorId: state.narratorId,
399
  };
@@ -449,10 +547,11 @@ function appendRenderEvent(payload) {
449
  ...payload,
450
  received_at: payload.received_at || new Date().toISOString(),
451
  };
452
- const previous = state.renderEvents[state.renderEvents.length - 1];
453
- if (previous && renderEventKey(previous) === renderEventKey(stamped)) {
454
  return;
455
  }
 
456
  state.renderEvents.push(stamped);
457
  }
458
 
@@ -466,6 +565,8 @@ function renderEventKey(event) {
466
  output_path: event.output_path,
467
  url: event.url,
468
  overall_progress: event.overall_progress,
 
 
469
  });
470
  }
471
 
@@ -497,6 +598,8 @@ function render() {
497
  if (nextReadingContainer && previousReadingChapterId === nextReadingChapterId) {
498
  nextReadingContainer.scrollTop = previousReadingScrollTop;
499
  }
 
 
500
  }
501
 
502
  function renderMasthead() {
@@ -507,7 +610,7 @@ function renderMasthead() {
507
  <div class="plaque">📖</div>
508
  <div>
509
  <div class="wordmark">Scriptorium</div>
510
- <div class="tagline">Bind your library into spoken word · OmniVoice TTS</div>
511
  </div>
512
  </div>
513
  <div class="steps">
@@ -582,30 +685,57 @@ function renderConfigure() {
582
  </div>
583
 
584
  <div class="voice-panel">
 
585
  <div class="seg">
586
- ${["auto", "clone", "design"].map((mode) => `
587
- <button type="button" data-action="setVoiceMode" data-mode="${mode}" class="${state.voiceMode === mode ? "active" : ""}">
588
- ${mode}
589
  </button>
590
  `).join("")}
591
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  ${renderVoiceMode()}
593
- <div class="dial-grid">
594
- <div>
595
- <div class="dial-head">
596
- <span class="smallcaps">Diffusion steps</span>
597
- <span class="dial-value">${state.diffusionSteps}</span>
 
 
 
598
  </div>
599
- <input id="diffusion-steps" class="range" type="range" min="8" max="64" value="${state.diffusionSteps}" />
600
- </div>
601
- <div>
602
- <div class="dial-head">
603
- <span class="smallcaps">Reading speed</span>
604
- <span class="dial-value">${state.speed.toFixed(1)}×</span>
605
  </div>
606
- <input id="reading-speed" class="range" type="range" min="0.5" max="2" step="0.1" value="${state.speed}" />
607
  </div>
608
- </div>
 
 
 
 
 
609
  </div>
610
  </div>
611
  </div>
@@ -631,7 +761,7 @@ function renderGenerate() {
631
  <div class="hero">
632
  ${renderCover({ w: 92, h: 134 })}
633
  <div class="hero-meta">
634
- <div class="smallcaps" style="display:flex; align-items:center; gap:9px; color:var(--leather);"><span class="status-dot"></span> Now binding · OmniVoice TTS</div>
635
  <div class="screen-title" style="font-size:31px; font-weight:800; margin:6px 0 14px;">
636
  Narrating <em style="color:var(--leather); font-style:italic;">${escapeHtml(activeChapter?.title || "your book")}</em>…
637
  </div>
@@ -645,7 +775,7 @@ function renderGenerate() {
645
  <div class="pct">${percent}<span style="font-size:24px;">%</span></div>
646
  <div class="smallcaps">complete</div>
647
  <div style="margin-top:10px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap;">
648
- <button class="small-btn" data-action="pauseRender">Pause</button>
649
  <button class="small-btn danger" data-action="cancelRender">Cancel</button>
650
  </div>
651
  </div>
@@ -699,7 +829,7 @@ function renderGenerate() {
699
  <div class="footer">
700
  <div class="footer-note">Binding <b>${included.length} chapters</b> · <b>${doneCount} done</b> · Export unlocks when complete</div>
701
  <div class="spacer"></div>
702
- <button class="btn-ghost" data-action="pauseRender">Pause binding</button>
703
  <button class="btn-primary" data-action="goExport" ${state.renderResult ? "" : "disabled"}>Continue to export</button>
704
  </div>
705
  `;
@@ -707,15 +837,19 @@ function renderGenerate() {
707
 
708
  function renderExport() {
709
  const tracks = includedChapters();
 
 
710
  let elapsed = 0;
711
  const trackRows = tracks.map((track, index) => {
712
  const start = elapsed;
713
  elapsed += Number(track.duration_seconds || track.est_minutes * 60 || 0);
714
- return { track, start, playing: index === 2 };
715
  });
716
  const totalSeconds = elapsed || Math.round(totalMinutes() * 60);
717
- const playbackSeconds = Math.min(totalSeconds, (trackRows[2]?.start || 0) + 300);
718
  const percent = totalSeconds ? (playbackSeconds / totalSeconds) * 100 : 0;
 
 
719
 
720
  return `
721
  <div class="hero">
@@ -733,7 +867,7 @@ function renderExport() {
733
  </div>
734
  <div>
735
  <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button>
736
- ${state.exportFile ? `<div style="margin-top:10px;"><a href="${state.exportFile.url}" target="_blank" rel="noopener">Open exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`}
737
  </div>
738
  </div>
739
 
@@ -743,13 +877,13 @@ function renderExport() {
743
  <h2 class="panel-title">Chapters &amp; markers</h2>
744
  <span class="right">${tracks.length} tracks</span>
745
  </div>
746
- <div class="queue">
747
  <div class="list-head">Chapter <span class="count">${formatRuntime(totalSeconds / 60)} total</span></div>
748
  <div class="track-list">
749
- ${trackRows.map(({ track, start, playing }) => `
750
  <div class="track-row ${playing ? "playing" : ""}">
751
  <span class="roman">${escapeHtml(track.n)}</span>
752
- <button class="circle-btn">${playing ? "❚❚" : "▶"}</button>
753
  <div class="track-main">
754
  <div class="track-name">${escapeHtml(track.title)}</div>
755
  <div class="track-meta">starts at ${formatStamp(start)}</div>
@@ -768,25 +902,25 @@ function renderExport() {
768
  <div style="display:flex; gap:18px; align-items:center; margin-bottom:18px;">
769
  ${renderCover({ w: 70, h: 102 })}
770
  <div>
771
- <div class="smallcaps">Now playing · chapter ${trackRows[2]?.track.n || "i"}</div>
772
- <div class="player-title" style="font-size:24px; font-weight:700;">${escapeHtml(trackRows[2]?.track.title || state.book?.title || "Preview")}</div>
773
  <div class="chapter-meta">narrated by ${escapeHtml(activeNarratorName())} · 1.0×</div>
774
  </div>
775
  </div>
776
- <div class="scrubber">
777
- <div class="scrub-fill" style="width:${percent}%"></div>
778
  ${trackRows.map(({ start }, index) => index ? `<span class="scrub-tick" style="left:${(start / totalSeconds) * 100}%"></span>` : "").join("")}
779
- <span class="scrub-head" style="left:${percent}%"></span>
780
  </div>
781
- <div class="track-meta" style="display:flex; justify-content:space-between;"> <span>${formatStamp(playbackSeconds)} elapsed</span> <span>−${formatRuntime((totalSeconds - playbackSeconds) / 60)}</span></div>
782
  <div class="transport">
783
- <button class="transport-btn">⏮</button>
784
- <button class="transport-btn">↺15</button>
785
- <button class="transport-btn main" data-action="playExportPreview">▶</button>
786
- <button class="transport-btn">30↻</button>
787
- <button class="transport-btn">⏭</button>
788
  </div>
789
- ${state.previewUrl ? `<audio id="preview-audio" src="${state.previewUrl}" preload="metadata"></audio>` : ""}
790
  </div>
791
  <div class="options">
792
  <div class="smallcaps" style="margin-bottom:9px;">Export format</div>
@@ -836,7 +970,7 @@ function renderCover(options = {}) {
836
  <span class="cover-corner bottom"></span>
837
  <div class="cover-title" style="font-size:${18 * scale}px;">${escapeHtml(state.book?.title || "Scriptorium")}</div>
838
  <div class="cover-rule" style="width:${38 * scale}px;"></div>
839
- <div class="cover-author" style="font-size:${13 * scale}px;">${escapeHtml(state.book?.author || "OmniVoice")}</div>
840
  </div>
841
  `;
842
  }
@@ -901,6 +1035,36 @@ function renderChapterBody(text) {
901
  }
902
 
903
  function renderVoiceMode() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  if (state.voiceMode === "clone") {
905
  return `
906
  <div class="drop-zone">
@@ -989,19 +1153,20 @@ function renderLogRow(event) {
989
  }
990
 
991
  function logMessage(event) {
 
992
  switch (event.type) {
993
  case "started":
994
- return `Began binding ${event.total_chapters} chapters`;
995
  case "chapter_started":
996
- return `Synthesising ${event.chapter_title}`;
997
  case "chapter_progress":
998
- return `${chapterTitle(event.chapter_id)} is still rendering`;
999
  case "chapter_done":
1000
- return `Bound ${chapterTitle(event.chapter_id)} · ${event.duration_seconds}s`;
1001
  case "completed":
1002
- return "Completed audiobook render";
1003
  case "cancelled":
1004
- return "Cancelled render";
1005
  default:
1006
  return JSON.stringify(event);
1007
  }
@@ -1019,9 +1184,166 @@ function renderCompletedChapterRow(chapter) {
1019
  `;
1020
  }
1021
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1022
  function canStartRender() {
1023
  if (!state.book) return false;
1024
  if (!selectedChapterIds().length) return false;
 
1025
  if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
1026
  if (state.voiceMode === "design" && !buildDesignPrompt()) return false;
1027
  return true;
@@ -1040,6 +1362,9 @@ function buildDesignPrompt() {
1040
  }
1041
 
1042
  function activeNarratorName() {
 
 
 
1043
  const narrator = NARRATORS.find((item) => item.id === state.narratorId);
1044
  return narrator?.name || "Custom narrator";
1045
  }
@@ -1050,9 +1375,58 @@ function chapterTitle(chapterId) {
1050
  }
1051
 
1052
  function syncNarratorMetadata() {
 
 
 
 
 
 
 
 
 
 
 
 
1053
  state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
1054
  }
1055
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1056
  function excerpt(text, length, start = 0) {
1057
  if (!text) return "";
1058
  return text.slice(start, start + length).trim();
@@ -1070,6 +1444,13 @@ function humanBytes(size) {
1070
  return `${value.toFixed(index ? 1 : 0)} ${units[index]}`;
1071
  }
1072
 
 
 
 
 
 
 
 
1073
  function escapeHtml(value) {
1074
  return String(value)
1075
  .replaceAll("&", "&amp;")
 
2
 
3
  const NARRATORS = globalThis.__VOICE_PRESETS__ || [];
4
  const VOICE_DESIGN_OPTIONS = globalThis.__VOICE_DESIGN_OPTIONS__ || [];
5
+ const MAGPIE_OPTIONS = globalThis.__MAGPIE_OPTIONS__ || {
6
+ speakers: [
7
+ { value: "Sofia", label: "Sofia" },
8
+ { value: "Aria", label: "Aria" },
9
+ { value: "Jason", label: "Jason" },
10
+ { value: "Leo", label: "Leo" },
11
+ { value: "John", label: "John" },
12
+ ],
13
+ languages: [
14
+ { value: "en", label: "English" },
15
+ ],
16
+ };
17
+ const SYNTHESIS_MODELS = globalThis.__SYNTHESIS_MODELS__ || [
18
+ { id: "omnivoice", name: "OmniVoice", desc: "Presets, design prompts, and voice cloning" },
19
+ { id: "magpie", name: "Magpie TTS", desc: "Multilingual speaker presets with text normalization" },
20
+ ];
21
+ const SYNTHESIS_BACKENDS = globalThis.__SYNTHESIS_BACKENDS__ || [
22
+ { id: "local", name: "HF/local", desc: "Run synthesis inside this Space runtime" },
23
+ { id: "modal", name: "Modal", desc: "Offload synthesis to a deployed Modal worker" },
24
+ ];
25
  const DEFAULT_NARRATOR = NARRATORS[0] || {
26
  id: "the-archivist",
27
  name: "The Archivist",
 
39
  book: null,
40
  chapters: [],
41
  currentChapterId: null,
42
+ model: "omnivoice",
43
+ backendSelection: "local",
44
  voiceMode: "auto",
45
  narratorId: DEFAULT_NARRATOR.id,
46
  cloneConsent: false,
47
  cloneSampleFile: null,
48
  cloneReferenceText: "",
49
+ magpieSpeaker: MAGPIE_OPTIONS.speakers?.[0]?.value || "Sofia",
50
+ magpieLanguage: MAGPIE_OPTIONS.languages?.[0]?.value || "en",
51
+ magpieApplyTextNormalization: false,
52
  designSelections: {
53
  gender: "",
54
  age: "",
 
62
  errorMessage: "",
63
  renderStatus: null,
64
  renderEvents: [],
65
+ renderEventKeys: new Set(),
66
  renderResult: null,
67
  renderSubmission: null,
68
+ activeRenderBackend: "local",
69
+ activeRenderModel: "omnivoice",
70
+ exportPlayerTrackIndex: 0,
71
  previewUrl: "",
72
  exportFormat: "m4a",
73
  embedMarkers: true,
 
148
  if (target.matches("[data-design-field]")) {
149
  state.designSelections[target.dataset.designField] = target.value;
150
  render();
151
+ return;
152
+ }
153
+ if (target.matches("#magpie-speaker")) {
154
+ state.magpieSpeaker = target.value;
155
+ syncNarratorMetadata();
156
+ render();
157
+ return;
158
+ }
159
+ if (target.matches("#magpie-language")) {
160
+ state.magpieLanguage = target.value;
161
+ render();
162
+ return;
163
+ }
164
+ if (target.matches("#magpie-tn")) {
165
+ state.magpieApplyTextNormalization = target.checked;
166
+ render();
167
  }
168
  }
169
 
 
199
  syncNarratorMetadata();
200
  render();
201
  },
202
+ setModel(target) {
203
+ state.model = target.dataset.model;
204
+ if (state.model !== "omnivoice") {
205
+ state.voiceMode = "auto";
206
+ }
207
+ syncNarratorMetadata();
208
+ render();
209
+ },
210
+ setBackend(target) {
211
+ state.backendSelection = target.dataset.backend;
212
+ render();
213
+ },
214
  setVoiceMode(target) {
215
  state.voiceMode = target.dataset.mode;
216
+ syncNarratorMetadata();
 
 
 
 
 
 
217
  render();
218
  },
219
  focusChapter(target) {
 
247
  render();
248
  },
249
  async exportBook() {
250
+ if (state.exportFile) {
251
+ downloadCurrentExport();
252
+ return;
253
+ }
254
  await requestExport();
255
  },
256
  goConfigure() {
 
274
  document.getElementById("clone-audio")?.click();
275
  },
276
  playExportPreview(target) {
277
+ const audio = exportAudioElement();
278
  if (!audio) return;
279
+ if (!audio.getAttribute("src")) {
280
+ playExportTrack(state.exportPlayerTrackIndex);
281
+ return;
282
+ }
283
  if (audio.paused) {
284
  audio.play();
 
285
  } else {
286
  audio.pause();
 
287
  }
288
+ syncExportControls();
289
+ },
290
+ playRenderedChapter(target) {
291
+ const trackIndex = Number(target.dataset.trackIndex || 0);
292
+ playExportTrack(trackIndex);
293
+ },
294
+ exportJumpToTrack(target) {
295
+ const direction = Number(target.dataset.direction || 0);
296
+ const tracks = playableTracks();
297
+ if (!tracks.length) return;
298
+ const nextIndex = clampTrackIndex(state.exportPlayerTrackIndex + direction, tracks.length);
299
+ playExportTrack(nextIndex);
300
+ },
301
+ exportSeek(target) {
302
+ const audio = exportAudioElement();
303
+ if (!audio) return;
304
+ const delta = Number(target.dataset.seconds || 0);
305
+ audio.currentTime = Math.max(0, Math.min(audio.duration || Infinity, audio.currentTime + delta));
306
+ syncExportControls();
307
  },
308
  };
309
 
 
357
  state.statusMessage = "Submitting audiobook render…";
358
  state.step = "generate";
359
  state.renderEvents = [];
360
+ state.renderEventKeys = new Set();
361
  state.renderResult = null;
362
+ state.activeRenderBackend = state.backendSelection;
363
+ state.activeRenderModel = state.model;
364
  render();
365
 
366
  const submission = state.client.submit("/start_render", {
 
396
  }
397
 
398
  function updateRenderState(payload) {
399
+ if (payload.backend) state.activeRenderBackend = payload.backend;
400
+ if (payload.model) state.activeRenderModel = payload.model;
401
  switch (payload.type) {
402
  case "started":
403
  state.statusMessage = `Binding ${payload.total_chapters} chapters…`;
 
445
  embed_markers: state.embedMarkers,
446
  });
447
  state.exportFile = payload;
448
+ state.exportPlayerTrackIndex = clampTrackIndex(state.exportPlayerTrackIndex, Math.max(1, includedChapters().length));
449
  state.previewUrl = payload.url || state.previewUrl;
450
  state.statusMessage = `${state.exportFormat.toUpperCase()} export ready.`;
451
  } catch (error) {
 
461
  }
462
 
463
  function buildVoiceConfig() {
464
+ if (state.model === "magpie") {
465
+ return {
466
+ model: "magpie",
467
+ backend: state.backendSelection,
468
+ mode: "auto",
469
+ speaker: state.magpieSpeaker,
470
+ language: state.magpieLanguage,
471
+ applyTextNormalization: state.magpieApplyTextNormalization,
472
+ };
473
+ }
474
  if (state.voiceMode === "clone") {
475
  return {
476
+ model: "omnivoice",
477
+ backend: state.backendSelection,
478
  mode: "clone",
479
  referenceText: state.cloneReferenceText,
480
  cloneConsent: state.cloneConsent,
 
483
  }
484
  if (state.voiceMode === "design") {
485
  return {
486
+ model: "omnivoice",
487
+ backend: state.backendSelection,
488
  mode: "design",
489
  designPrompt: buildDesignPrompt(),
490
  };
491
  }
492
  return {
493
+ model: "omnivoice",
494
+ backend: state.backendSelection,
495
  mode: "auto",
496
  narratorId: state.narratorId,
497
  };
 
547
  ...payload,
548
  received_at: payload.received_at || new Date().toISOString(),
549
  };
550
+ const key = renderEventKey(stamped);
551
+ if (state.renderEventKeys.has(key)) {
552
  return;
553
  }
554
+ state.renderEventKeys.add(key);
555
  state.renderEvents.push(stamped);
556
  }
557
 
 
565
  output_path: event.output_path,
566
  url: event.url,
567
  overall_progress: event.overall_progress,
568
+ backend: event.backend,
569
+ model: event.model,
570
  });
571
  }
572
 
 
598
  if (nextReadingContainer && previousReadingChapterId === nextReadingChapterId) {
599
  nextReadingContainer.scrollTop = previousReadingScrollTop;
600
  }
601
+ bindExportAudio();
602
+ syncExportControls();
603
  }
604
 
605
  function renderMasthead() {
 
610
  <div class="plaque">📖</div>
611
  <div>
612
  <div class="wordmark">Scriptorium</div>
613
+ <div class="tagline">Bind your library into spoken word · OmniVoice + Magpie TTS</div>
614
  </div>
615
  </div>
616
  <div class="steps">
 
685
  </div>
686
 
687
  <div class="voice-panel">
688
+ <div class="smallcaps" style="margin-bottom:9px;">Speech model</div>
689
  <div class="seg">
690
+ ${SYNTHESIS_MODELS.map((model) => `
691
+ <button type="button" data-action="setModel" data-model="${model.id}" class="${state.model === model.id ? "active" : ""}">
692
+ ${escapeHtml(model.name)}
693
  </button>
694
  `).join("")}
695
  </div>
696
+ <div class="chapter-meta" style="margin:10px 0 14px;">${escapeHtml(activeModelDescription())}</div>
697
+ <div class="smallcaps" style="margin-bottom:9px;">Execution backend</div>
698
+ <div class="seg">
699
+ ${SYNTHESIS_BACKENDS.map((backend) => `
700
+ <button type="button" data-action="setBackend" data-backend="${backend.id}" class="${state.backendSelection === backend.id ? "active" : ""}">
701
+ ${escapeHtml(backend.name)}
702
+ </button>
703
+ `).join("")}
704
+ </div>
705
+ <div class="chapter-meta" style="margin:10px 0 14px;">${escapeHtml(activeBackendDescription())}</div>
706
+ ${state.model === "omnivoice" ? `
707
+ <div class="seg">
708
+ ${["auto", "clone", "design"].map((mode) => `
709
+ <button type="button" data-action="setVoiceMode" data-mode="${mode}" class="${state.voiceMode === mode ? "active" : ""}">
710
+ ${mode}
711
+ </button>
712
+ `).join("")}
713
+ </div>
714
+ ` : ""}
715
  ${renderVoiceMode()}
716
+ ${state.model === "omnivoice" ? `
717
+ <div class="dial-grid">
718
+ <div>
719
+ <div class="dial-head">
720
+ <span class="smallcaps">Diffusion steps</span>
721
+ <span class="dial-value">${state.diffusionSteps}</span>
722
+ </div>
723
+ <input id="diffusion-steps" class="range" type="range" min="8" max="64" value="${state.diffusionSteps}" />
724
  </div>
725
+ <div>
726
+ <div class="dial-head">
727
+ <span class="smallcaps">Reading speed</span>
728
+ <span class="dial-value">${state.speed.toFixed(1)}×</span>
729
+ </div>
730
+ <input id="reading-speed" class="range" type="range" min="0.5" max="2" step="0.1" value="${state.speed}" />
731
  </div>
 
732
  </div>
733
+ ` : `
734
+ <div class="design-preview" style="margin-top:14px;">
735
+ <div class="smallcaps" style="margin-bottom:7px;">Magpie controls</div>
736
+ <div class="field design-output">Speaker, language, and text normalization are controlled directly for Magpie. Speed and diffusion controls are OmniVoice-only.</div>
737
+ </div>
738
+ `}
739
  </div>
740
  </div>
741
  </div>
 
761
  <div class="hero">
762
  ${renderCover({ w: 92, h: 134 })}
763
  <div class="hero-meta">
764
+ <div class="smallcaps" style="display:flex; align-items:center; gap:9px; color:var(--leather);"><span class="status-dot"></span> Now binding · ${escapeHtml(currentRenderStackLabel())}</div>
765
  <div class="screen-title" style="font-size:31px; font-weight:800; margin:6px 0 14px;">
766
  Narrating <em style="color:var(--leather); font-style:italic;">${escapeHtml(activeChapter?.title || "your book")}</em>…
767
  </div>
 
775
  <div class="pct">${percent}<span style="font-size:24px;">%</span></div>
776
  <div class="smallcaps">complete</div>
777
  <div style="margin-top:10px; display:flex; gap:10px; justify-content:flex-end; flex-wrap:wrap;">
778
+ ${renderSupportsPause() ? `<button class="small-btn" data-action="pauseRender">Pause</button>` : ""}
779
  <button class="small-btn danger" data-action="cancelRender">Cancel</button>
780
  </div>
781
  </div>
 
829
  <div class="footer">
830
  <div class="footer-note">Binding <b>${included.length} chapters</b> · <b>${doneCount} done</b> · Export unlocks when complete</div>
831
  <div class="spacer"></div>
832
+ ${renderSupportsPause() ? `<button class="btn-ghost" data-action="pauseRender">Pause binding</button>` : ""}
833
  <button class="btn-primary" data-action="goExport" ${state.renderResult ? "" : "disabled"}>Continue to export</button>
834
  </div>
835
  `;
 
837
 
838
  function renderExport() {
839
  const tracks = includedChapters();
840
+ const activeTrackIndex = clampTrackIndex(state.exportPlayerTrackIndex, Math.max(1, tracks.length));
841
+ state.exportPlayerTrackIndex = activeTrackIndex;
842
  let elapsed = 0;
843
  const trackRows = tracks.map((track, index) => {
844
  const start = elapsed;
845
  elapsed += Number(track.duration_seconds || track.est_minutes * 60 || 0);
846
+ return { track, start, playing: index === activeTrackIndex };
847
  });
848
  const totalSeconds = elapsed || Math.round(totalMinutes() * 60);
849
+ const playbackSeconds = Math.min(totalSeconds, trackRows[activeTrackIndex]?.start || 0);
850
  const percent = totalSeconds ? (playbackSeconds / totalSeconds) * 100 : 0;
851
+ const currentTrack = trackRows[activeTrackIndex]?.track;
852
+ const exportUrl = currentExportAudioUrl();
853
 
854
  return `
855
  <div class="hero">
 
867
  </div>
868
  <div>
869
  <button class="btn-primary" data-action="exportBook">${state.exportFile ? "Download again" : "Download audiobook"}</button>
870
+ ${state.exportFile ? `<div style="margin-top:10px;"><a href="${state.exportFile.url}" target="_blank" rel="noopener" download="${escapeAttr(exportFilename())}">Open exported file</a></div>` : `<div style="margin-top:10px; color:var(--faint); font-size:12.5px;">with embedded chapter markers</div>`}
871
  </div>
872
  </div>
873
 
 
877
  <h2 class="panel-title">Chapters &amp; markers</h2>
878
  <span class="right">${tracks.length} tracks</span>
879
  </div>
880
+ <div class="queue">
881
  <div class="list-head">Chapter <span class="count">${formatRuntime(totalSeconds / 60)} total</span></div>
882
  <div class="track-list">
883
+ ${trackRows.map(({ track, start, playing }, index) => `
884
  <div class="track-row ${playing ? "playing" : ""}">
885
  <span class="roman">${escapeHtml(track.n)}</span>
886
+ <button class="circle-btn" data-action="playRenderedChapter" data-track-index="${index}">${playing ? "❚❚" : "▶"}</button>
887
  <div class="track-main">
888
  <div class="track-name">${escapeHtml(track.title)}</div>
889
  <div class="track-meta">starts at ${formatStamp(start)}</div>
 
902
  <div style="display:flex; gap:18px; align-items:center; margin-bottom:18px;">
903
  ${renderCover({ w: 70, h: 102 })}
904
  <div>
905
+ <div class="smallcaps">Now playing · chapter ${currentTrack?.n || "i"}</div>
906
+ <div class="player-title" style="font-size:24px; font-weight:700;">${escapeHtml(currentTrack?.title || state.book?.title || "Preview")}</div>
907
  <div class="chapter-meta">narrated by ${escapeHtml(activeNarratorName())} · 1.0×</div>
908
  </div>
909
  </div>
910
+ <div class="scrubber" data-export-scrubber>
911
+ <div class="scrub-fill" data-export-scrub-fill style="width:${percent}%"></div>
912
  ${trackRows.map(({ start }, index) => index ? `<span class="scrub-tick" style="left:${(start / totalSeconds) * 100}%"></span>` : "").join("")}
913
+ <span class="scrub-head" data-export-scrub-head style="left:${percent}%"></span>
914
  </div>
915
+ <div class="track-meta" style="display:flex; justify-content:space-between;"> <span data-export-elapsed>${formatStamp(playbackSeconds)} elapsed</span> <span data-export-remaining>−${formatRuntime((totalSeconds - playbackSeconds) / 60)}</span></div>
916
  <div class="transport">
917
+ <button class="transport-btn" data-action="exportJumpToTrack" data-direction="-1">⏮</button>
918
+ <button class="transport-btn" data-action="exportSeek" data-seconds="-15">↺15</button>
919
+ <button class="transport-btn main" data-action="playExportPreview" data-export-play>▶</button>
920
+ <button class="transport-btn" data-action="exportSeek" data-seconds="30">30↻</button>
921
+ <button class="transport-btn" data-action="exportJumpToTrack" data-direction="1">⏭</button>
922
  </div>
923
+ ${exportUrl ? `<audio id="preview-audio" src="${escapeAttr(exportUrl)}" preload="metadata"></audio>` : ""}
924
  </div>
925
  <div class="options">
926
  <div class="smallcaps" style="margin-bottom:9px;">Export format</div>
 
970
  <span class="cover-corner bottom"></span>
971
  <div class="cover-title" style="font-size:${18 * scale}px;">${escapeHtml(state.book?.title || "Scriptorium")}</div>
972
  <div class="cover-rule" style="width:${38 * scale}px;"></div>
973
+ <div class="cover-author" style="font-size:${13 * scale}px;">${escapeHtml(state.book?.author || activeModelName())}</div>
974
  </div>
975
  `;
976
  }
 
1035
  }
1036
 
1037
  function renderVoiceMode() {
1038
+ if (state.model === "magpie") {
1039
+ return `
1040
+ <div class="design-grid">
1041
+ <label style="display:block;">
1042
+ <div class="smallcaps" style="margin-bottom:7px;">Speaker</div>
1043
+ <select id="magpie-speaker" class="field">
1044
+ ${MAGPIE_OPTIONS.speakers.map((speaker) => `
1045
+ <option value="${escapeAttr(speaker.value)}" ${state.magpieSpeaker === speaker.value ? "selected" : ""}>
1046
+ ${escapeHtml(speaker.label)}
1047
+ </option>
1048
+ `).join("")}
1049
+ </select>
1050
+ </label>
1051
+ <label style="display:block;">
1052
+ <div class="smallcaps" style="margin-bottom:7px;">Language</div>
1053
+ <select id="magpie-language" class="field">
1054
+ ${MAGPIE_OPTIONS.languages.map((language) => `
1055
+ <option value="${escapeAttr(language.value)}" ${state.magpieLanguage === language.value ? "selected" : ""}>
1056
+ ${escapeHtml(language.label)}
1057
+ </option>
1058
+ `).join("")}
1059
+ </select>
1060
+ </label>
1061
+ </div>
1062
+ <label style="display:flex; gap:10px; align-items:flex-start; margin-top:14px;">
1063
+ <input id="magpie-tn" type="checkbox" ${state.magpieApplyTextNormalization ? "checked" : ""} />
1064
+ <span>Apply text normalization for numbers, abbreviations, and special characters when supported by the selected language.</span>
1065
+ </label>
1066
+ `;
1067
+ }
1068
  if (state.voiceMode === "clone") {
1069
  return `
1070
  <div class="drop-zone">
 
1153
  }
1154
 
1155
  function logMessage(event) {
1156
+ const suffix = event.backend === "modal" ? " via Modal" : "";
1157
  switch (event.type) {
1158
  case "started":
1159
+ return `Began binding ${event.total_chapters} chapters${suffix}`;
1160
  case "chapter_started":
1161
+ return `Synthesising ${event.chapter_title}${suffix}`;
1162
  case "chapter_progress":
1163
+ return `${chapterTitle(event.chapter_id)} is still rendering${suffix}`;
1164
  case "chapter_done":
1165
+ return `Bound ${chapterTitle(event.chapter_id)} · ${event.duration_seconds}s${suffix}`;
1166
  case "completed":
1167
+ return `Completed audiobook render${suffix}`;
1168
  case "cancelled":
1169
+ return `Cancelled render${suffix}`;
1170
  default:
1171
  return JSON.stringify(event);
1172
  }
 
1184
  `;
1185
  }
1186
 
1187
+ function bindExportAudio() {
1188
+ const audio = exportAudioElement();
1189
+ if (!audio || audio.dataset.bound === "true") return;
1190
+ audio.dataset.bound = "true";
1191
+ audio.addEventListener("play", syncExportControls);
1192
+ audio.addEventListener("pause", syncExportControls);
1193
+ audio.addEventListener("loadedmetadata", syncExportControls);
1194
+ audio.addEventListener("timeupdate", syncExportControls);
1195
+ audio.addEventListener("ended", handleExportAudioEnded);
1196
+ }
1197
+
1198
+ function handleExportAudioEnded() {
1199
+ const audio = exportAudioElement();
1200
+ if (!audio) return;
1201
+ const tracks = playableTracks();
1202
+ if (!tracks.length) {
1203
+ syncExportControls();
1204
+ return;
1205
+ }
1206
+ if (usingMergedExportAudio()) {
1207
+ const nextIndex = state.exportPlayerTrackIndex + 1;
1208
+ if (nextIndex < tracks.length) {
1209
+ playExportTrack(nextIndex);
1210
+ return;
1211
+ }
1212
+ } else {
1213
+ const nextIndex = state.exportPlayerTrackIndex + 1;
1214
+ if (nextIndex < tracks.length) {
1215
+ playExportTrack(nextIndex);
1216
+ return;
1217
+ }
1218
+ }
1219
+ syncExportControls();
1220
+ }
1221
+
1222
+ function exportAudioElement() {
1223
+ return document.getElementById("preview-audio");
1224
+ }
1225
+
1226
+ function playableTracks() {
1227
+ return includedChapters();
1228
+ }
1229
+
1230
+ function clampTrackIndex(index, length) {
1231
+ if (!length) return 0;
1232
+ return Math.max(0, Math.min(length - 1, index));
1233
+ }
1234
+
1235
+ function usingMergedExportAudio() {
1236
+ return Boolean(state.exportFile && state.exportFormat !== "zip");
1237
+ }
1238
+
1239
+ function exportTrackStartSeconds(trackIndex) {
1240
+ const tracks = playableTracks();
1241
+ let elapsed = 0;
1242
+ for (let index = 0; index < tracks.length; index += 1) {
1243
+ if (index === trackIndex) return elapsed;
1244
+ elapsed += Number(tracks[index].duration_seconds || tracks[index].est_minutes * 60 || 0);
1245
+ }
1246
+ return 0;
1247
+ }
1248
+
1249
+ function currentExportAudioUrl() {
1250
+ if (usingMergedExportAudio()) return state.exportFile?.url || "";
1251
+ const tracks = playableTracks();
1252
+ return tracks[state.exportPlayerTrackIndex]?.render_url || state.previewUrl || "";
1253
+ }
1254
+
1255
+ function playExportTrack(trackIndex) {
1256
+ const tracks = playableTracks();
1257
+ if (!tracks.length) return;
1258
+ const nextIndex = clampTrackIndex(trackIndex, tracks.length);
1259
+ state.exportPlayerTrackIndex = nextIndex;
1260
+ const audio = exportAudioElement();
1261
+ const nextUrl = usingMergedExportAudio()
1262
+ ? state.exportFile?.url || ""
1263
+ : tracks[nextIndex]?.render_url || state.previewUrl || "";
1264
+ if (!audio || !nextUrl) return;
1265
+ if (audio.getAttribute("src") !== nextUrl) {
1266
+ audio.setAttribute("src", nextUrl);
1267
+ audio.load();
1268
+ }
1269
+ const startSeconds = usingMergedExportAudio() ? exportTrackStartSeconds(nextIndex) : 0;
1270
+ const playWhenReady = () => {
1271
+ audio.currentTime = startSeconds;
1272
+ void audio.play();
1273
+ audio.removeEventListener("loadedmetadata", playWhenReady);
1274
+ syncExportControls();
1275
+ };
1276
+ if (audio.readyState < 1) {
1277
+ audio.addEventListener("loadedmetadata", playWhenReady);
1278
+ } else {
1279
+ audio.currentTime = startSeconds;
1280
+ void audio.play();
1281
+ }
1282
+ syncExportControls();
1283
+ }
1284
+
1285
+ function syncExportControls() {
1286
+ const audio = exportAudioElement();
1287
+ const playButton = document.querySelector("[data-export-play]");
1288
+ if (playButton) {
1289
+ playButton.textContent = audio && !audio.paused ? "❚❚" : "▶";
1290
+ }
1291
+ const fill = document.querySelector("[data-export-scrub-fill]");
1292
+ const head = document.querySelector("[data-export-scrub-head]");
1293
+ const elapsed = document.querySelector("[data-export-elapsed]");
1294
+ const remaining = document.querySelector("[data-export-remaining]");
1295
+ if (!audio || !fill || !head || !elapsed || !remaining) return;
1296
+
1297
+ const percent = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0;
1298
+ fill.style.width = `${percent}%`;
1299
+ head.style.left = `${percent}%`;
1300
+ elapsed.textContent = `${formatStamp(audio.currentTime)} elapsed`;
1301
+ remaining.textContent = audio.duration
1302
+ ? `−${formatStamp(Math.max(0, audio.duration - audio.currentTime))}`
1303
+ : "−0:00:00";
1304
+
1305
+ if (usingMergedExportAudio()) {
1306
+ const trackIndex = currentTrackIndexForTime(audio.currentTime);
1307
+ if (trackIndex !== state.exportPlayerTrackIndex) {
1308
+ state.exportPlayerTrackIndex = trackIndex;
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ function currentTrackIndexForTime(seconds) {
1314
+ const tracks = playableTracks();
1315
+ let elapsed = 0;
1316
+ for (let index = 0; index < tracks.length; index += 1) {
1317
+ elapsed += Number(tracks[index].duration_seconds || tracks[index].est_minutes * 60 || 0);
1318
+ if (seconds < elapsed) return index;
1319
+ }
1320
+ return clampTrackIndex(tracks.length - 1, Math.max(1, tracks.length));
1321
+ }
1322
+
1323
+ function exportFilename() {
1324
+ const file = state.exportFile?.file || "";
1325
+ if (file) {
1326
+ const pieces = String(file).split("/");
1327
+ return pieces[pieces.length - 1];
1328
+ }
1329
+ const base = slugify(state.book?.title || "scriptorium-audiobook");
1330
+ return `${base}.${state.exportFormat}`;
1331
+ }
1332
+
1333
+ function downloadCurrentExport() {
1334
+ if (!state.exportFile?.url) return;
1335
+ const link = document.createElement("a");
1336
+ link.href = state.exportFile.url;
1337
+ link.download = exportFilename();
1338
+ document.body.appendChild(link);
1339
+ link.click();
1340
+ link.remove();
1341
+ }
1342
+
1343
  function canStartRender() {
1344
  if (!state.book) return false;
1345
  if (!selectedChapterIds().length) return false;
1346
+ if (state.model !== "omnivoice") return true;
1347
  if (state.voiceMode === "clone" && (!state.cloneSampleFile || !state.cloneConsent)) return false;
1348
  if (state.voiceMode === "design" && !buildDesignPrompt()) return false;
1349
  return true;
 
1362
  }
1363
 
1364
  function activeNarratorName() {
1365
+ if (state.model === "magpie") {
1366
+ return `${state.magpieSpeaker} (${activeLanguageLabel()})`;
1367
+ }
1368
  const narrator = NARRATORS.find((item) => item.id === state.narratorId);
1369
  return narrator?.name || "Custom narrator";
1370
  }
 
1375
  }
1376
 
1377
  function syncNarratorMetadata() {
1378
+ if (state.model === "magpie") {
1379
+ state.exportMetadata.narrator = `${activeNarratorName()} (Magpie TTS)`;
1380
+ return;
1381
+ }
1382
+ if (state.voiceMode === "clone") {
1383
+ state.exportMetadata.narrator = "Cloned Narrator (OmniVoice)";
1384
+ return;
1385
+ }
1386
+ if (state.voiceMode === "design") {
1387
+ state.exportMetadata.narrator = "Designed Narrator (OmniVoice)";
1388
+ return;
1389
+ }
1390
  state.exportMetadata.narrator = `${activeNarratorName()} (OmniVoice)`;
1391
  }
1392
 
1393
+ function activeModel() {
1394
+ return SYNTHESIS_MODELS.find((item) => item.id === state.model) || SYNTHESIS_MODELS[0];
1395
+ }
1396
+
1397
+ function activeModelName() {
1398
+ return activeModel()?.name || "Speech model";
1399
+ }
1400
+
1401
+ function activeModelDescription() {
1402
+ return activeModel()?.desc || "";
1403
+ }
1404
+
1405
+ function activeBackend() {
1406
+ return SYNTHESIS_BACKENDS.find((item) => item.id === state.backendSelection) || SYNTHESIS_BACKENDS[0];
1407
+ }
1408
+
1409
+ function activeBackendDescription() {
1410
+ return activeBackend()?.desc || "";
1411
+ }
1412
+
1413
+ function activeLanguageLabel() {
1414
+ const language = MAGPIE_OPTIONS.languages.find((item) => item.value === state.magpieLanguage);
1415
+ return language?.label || state.magpieLanguage;
1416
+ }
1417
+
1418
+ function currentRenderStackLabel() {
1419
+ const modelName =
1420
+ (SYNTHESIS_MODELS.find((item) => item.id === state.activeRenderModel)?.name || activeModelName());
1421
+ const backendName =
1422
+ (SYNTHESIS_BACKENDS.find((item) => item.id === state.activeRenderBackend)?.name || activeBackend()?.name || "HF/local");
1423
+ return `${modelName} · ${backendName}`;
1424
+ }
1425
+
1426
+ function renderSupportsPause() {
1427
+ return state.activeRenderBackend !== "modal";
1428
+ }
1429
+
1430
  function excerpt(text, length, start = 0) {
1431
  if (!text) return "";
1432
  return text.slice(start, start + length).trim();
 
1444
  return `${value.toFixed(index ? 1 : 0)} ${units[index]}`;
1445
  }
1446
 
1447
+ function slugify(value) {
1448
+ return String(value)
1449
+ .toLowerCase()
1450
+ .replace(/[^a-z0-9]+/g, "-")
1451
+ .replace(/^-+|-+$/g, "") || "scriptorium-audiobook";
1452
+ }
1453
+
1454
  function escapeHtml(value) {
1455
  return String(value)
1456
  .replaceAll("&", "&amp;")
modal_app.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import os
3
+ import tempfile
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List
6
+ from uuid import uuid4
7
+
8
+ import fastapi
9
+ import modal
10
+ from fastapi import Header, HTTPException
11
+ from fastapi.responses import Response
12
+
13
+ from backend.magpie_adapter import MagpieAdapter
14
+ from backend.omnivoice_adapter import OmniVoiceAdapter
15
+ from backend.synthesis_catalog import MODAL_BACKEND
16
+ from backend.types import VoiceConfig
17
+
18
+
19
+ app = modal.App("scriptorium-tts")
20
+ image = (
21
+ modal.Image.debian_slim(python_version="3.12")
22
+ .pip_install(
23
+ "fastapi[standard]",
24
+ "numpy>=1.26.0",
25
+ "soundfile>=0.13.0",
26
+ "torch>=2.8.0",
27
+ "torchaudio>=2.8.0",
28
+ "omnivoice>=0.1.5",
29
+ "requests>=2.32.0",
30
+ "huggingface_hub>=0.33.0",
31
+ "nemo-toolkit[tts]",
32
+ "kaldialign",
33
+ )
34
+ )
35
+ jobs = modal.Dict.from_name("scriptorium-modal-jobs", create_if_missing=True)
36
+ artifacts = modal.Dict.from_name("scriptorium-modal-artifacts", create_if_missing=True)
37
+ web_app = fastapi.FastAPI()
38
+
39
+
40
+ def _check_auth(header: str | None) -> None:
41
+ expected = os.getenv("SCRIPTORIUM_MODAL_SHARED_SECRET", "").strip()
42
+ if not expected:
43
+ return
44
+ token = (header or "").removeprefix("Bearer ").strip()
45
+ if token != expected:
46
+ raise HTTPException(status_code=401, detail="Unauthorized")
47
+
48
+
49
+ def _adapter_for(model: str):
50
+ if model == "magpie":
51
+ return MagpieAdapter()
52
+ return OmniVoiceAdapter()
53
+
54
+
55
+ def _voice_config(payload: Dict[str, Any], temp_dir: Path) -> VoiceConfig:
56
+ data = dict(payload)
57
+ sample_b64 = data.pop("sample_b64", None)
58
+ voice = VoiceConfig.from_dict(data)
59
+ if sample_b64:
60
+ sample_path = temp_dir / "clone-sample.wav"
61
+ sample_path.write_bytes(base64.b64decode(sample_b64))
62
+ voice.sample_path = str(sample_path)
63
+ return voice
64
+
65
+
66
+ @app.function(image=image, gpu="any", timeout=900)
67
+ def render_preview(payload: Dict[str, Any]) -> Dict[str, Any]:
68
+ with tempfile.TemporaryDirectory() as tmp:
69
+ temp_dir = Path(tmp)
70
+ output_path = temp_dir / "preview.wav"
71
+ voice = _voice_config(payload["voice_config"], temp_dir)
72
+ result = _adapter_for(voice.model).synthesize(
73
+ text=str(payload["text"]),
74
+ output_path=output_path,
75
+ voice_config=voice,
76
+ diffusion_steps=int(payload.get("diffusion_steps", 32)),
77
+ speed=float(payload.get("speed", 1.0)),
78
+ )
79
+ return {
80
+ "audio_base64": base64.b64encode(output_path.read_bytes()).decode("ascii"),
81
+ "duration_seconds": result.get("duration_seconds", 0),
82
+ "sample_rate": result.get("sample_rate", 24000),
83
+ "backend": MODAL_BACKEND,
84
+ "model": voice.model,
85
+ }
86
+
87
+
88
+ def _job_state(job_id: str) -> Dict[str, Any]:
89
+ return dict(
90
+ jobs.get(
91
+ job_id,
92
+ {
93
+ "status": "pending",
94
+ "events": [],
95
+ "cancel_requested": False,
96
+ },
97
+ )
98
+ )
99
+
100
+
101
+ def _save_job_state(job_id: str, state: Dict[str, Any]) -> None:
102
+ jobs[job_id] = state
103
+
104
+
105
+ def _append_event(job_id: str, event: Dict[str, Any]) -> None:
106
+ state = _job_state(job_id)
107
+ state.setdefault("events", []).append(event)
108
+ state["status"] = event.get("type", state.get("status", "running"))
109
+ _save_job_state(job_id, state)
110
+
111
+
112
+ @app.function(image=image, gpu="any", timeout=60 * 60)
113
+ def render_book(job_id: str, payload: Dict[str, Any]) -> None:
114
+ with tempfile.TemporaryDirectory() as tmp:
115
+ temp_dir = Path(tmp)
116
+ voice = _voice_config(payload["voice_config"], temp_dir)
117
+ chapters = [chapter for chapter in payload["chapters"] if chapter.get("included", True)]
118
+ state = _job_state(job_id)
119
+ state["status"] = "running"
120
+ _save_job_state(job_id, state)
121
+ _append_event(
122
+ job_id,
123
+ {
124
+ "type": "started",
125
+ "session_id": payload["session_id"],
126
+ "total_chapters": len(chapters),
127
+ "book_title": payload["book"].get("title"),
128
+ "backend": MODAL_BACKEND,
129
+ "model": voice.model,
130
+ },
131
+ )
132
+
133
+ adapter = _adapter_for(voice.model)
134
+ outputs: List[str] = []
135
+ for index, chapter in enumerate(chapters):
136
+ state = _job_state(job_id)
137
+ if state.get("cancel_requested"):
138
+ _append_event(
139
+ job_id,
140
+ {
141
+ "type": "cancelled",
142
+ "session_id": payload["session_id"],
143
+ "backend": MODAL_BACKEND,
144
+ "model": voice.model,
145
+ },
146
+ )
147
+ return
148
+
149
+ chapter_id = str(chapter["id"])
150
+ _append_event(
151
+ job_id,
152
+ {
153
+ "type": "chapter_started",
154
+ "session_id": payload["session_id"],
155
+ "chapter_id": chapter_id,
156
+ "chapter_title": chapter["title"],
157
+ "chapter_index": index,
158
+ "overall_progress": index / max(1, len(chapters)),
159
+ "backend": MODAL_BACKEND,
160
+ "model": voice.model,
161
+ },
162
+ )
163
+ output_path = temp_dir / f"{index + 1:03d}-{chapter_id}.wav"
164
+ result = adapter.synthesize(
165
+ text=str(chapter["text"]),
166
+ output_path=output_path,
167
+ voice_config=voice,
168
+ diffusion_steps=int(payload.get("diffusion_steps", 32)),
169
+ speed=float(payload.get("speed", 1.0)),
170
+ )
171
+ filename = output_path.name
172
+ artifacts[f"{job_id}:{filename}"] = output_path.read_bytes()
173
+ outputs.append(filename)
174
+ _append_event(
175
+ job_id,
176
+ {
177
+ "type": "chapter_done",
178
+ "session_id": payload["session_id"],
179
+ "chapter_id": chapter_id,
180
+ "duration_seconds": int(result.get("duration_seconds", 0)),
181
+ "overall_progress": (index + 1) / max(1, len(chapters)),
182
+ "filename": filename,
183
+ "artifact_url": f"/artifacts/{job_id}/{filename}",
184
+ "backend": MODAL_BACKEND,
185
+ "model": voice.model,
186
+ },
187
+ )
188
+
189
+ _append_event(
190
+ job_id,
191
+ {
192
+ "type": "completed",
193
+ "session_id": payload["session_id"],
194
+ "outputs": outputs,
195
+ "backend": MODAL_BACKEND,
196
+ "model": voice.model,
197
+ },
198
+ )
199
+
200
+
201
+ @web_app.post("/preview")
202
+ def preview_endpoint(
203
+ payload: Dict[str, Any],
204
+ authorization: str | None = Header(default=None),
205
+ ) -> Dict[str, Any]:
206
+ _check_auth(authorization)
207
+ return render_preview.remote(payload)
208
+
209
+
210
+ @web_app.post("/renders")
211
+ def submit_render_endpoint(
212
+ payload: Dict[str, Any],
213
+ authorization: str | None = Header(default=None),
214
+ ) -> Dict[str, str]:
215
+ _check_auth(authorization)
216
+ job_id = str(uuid4())
217
+ _save_job_state(job_id, {"status": "pending", "events": [], "cancel_requested": False})
218
+ render_book.spawn(job_id, payload)
219
+ return {"job_id": job_id}
220
+
221
+
222
+ @web_app.get("/renders/{job_id}")
223
+ def render_status_endpoint(
224
+ job_id: str,
225
+ cursor: int = 0,
226
+ authorization: str | None = Header(default=None),
227
+ ) -> Dict[str, Any]:
228
+ _check_auth(authorization)
229
+ state = _job_state(job_id)
230
+ events = list(state.get("events", []))
231
+ return {
232
+ "job_id": job_id,
233
+ "status": state.get("status", "pending"),
234
+ "events": events[cursor:],
235
+ }
236
+
237
+
238
+ @web_app.post("/renders/{job_id}/cancel")
239
+ def cancel_render_endpoint(
240
+ job_id: str,
241
+ authorization: str | None = Header(default=None),
242
+ ) -> Dict[str, str]:
243
+ _check_auth(authorization)
244
+ state = _job_state(job_id)
245
+ state["cancel_requested"] = True
246
+ state["status"] = "cancelled"
247
+ _save_job_state(job_id, state)
248
+ return {"type": "cancelled", "job_id": job_id}
249
+
250
+
251
+ @web_app.get("/artifacts/{job_id}/{filename}")
252
+ def artifact_endpoint(
253
+ job_id: str,
254
+ filename: str,
255
+ authorization: str | None = Header(default=None),
256
+ ):
257
+ _check_auth(authorization)
258
+ content = artifacts.get(f"{job_id}:{filename}")
259
+ if content is None:
260
+ raise HTTPException(status_code=404, detail="Artifact not found")
261
+ return Response(content=content, media_type="audio/wav")
262
+
263
+
264
+ @app.function(image=image)
265
+ @modal.asgi_app()
266
+ def fastapi_app():
267
+ return web_app
requirements.txt CHANGED
@@ -8,3 +8,5 @@ numpy>=1.26.0
8
  torch>=2.8.0
9
  torchaudio>=2.8.0
10
  omnivoice>=0.1.5
 
 
 
8
  torch>=2.8.0
9
  torchaudio>=2.8.0
10
  omnivoice>=0.1.5
11
+ requests>=2.32.0
12
+ huggingface_hub>=0.33.0
tests/test_frontend_export_controls.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+
4
+ def test_export_screen_wires_track_and_transport_actions() -> None:
5
+ source = Path("frontend/app.js").read_text(encoding="utf-8")
6
+
7
+ assert 'data-action="playRenderedChapter"' in source
8
+ assert 'data-action="exportJumpToTrack"' in source
9
+ assert 'data-action="exportSeek"' in source
10
+ assert "playRenderedChapter(target)" in source
11
+ assert "exportJumpToTrack(target)" in source
12
+ assert "exportSeek(target)" in source
13
+
14
+
15
+ def test_export_screen_uses_downloadable_export_links() -> None:
16
+ source = Path("frontend/app.js").read_text(encoding="utf-8")
17
+
18
+ assert "downloadCurrentExport()" in source
19
+ assert 'download="${escapeAttr(exportFilename())}"' in source
tests/test_omnivoice_adapter.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import numpy as np
4
+
5
+ from backend.omnivoice_adapter import OmniVoiceAdapter
6
+ from backend.types import VoiceConfig
7
+
8
+
9
+ class FakeModel:
10
+ def __init__(self) -> None:
11
+ self.generate_calls = []
12
+ self.clone_prompt_calls = []
13
+
14
+ def create_voice_clone_prompt(self, *, ref_audio, ref_text=None):
15
+ self.clone_prompt_calls.append({"ref_audio": ref_audio, "ref_text": ref_text})
16
+ return {"prompt": "cached"}
17
+
18
+ def generate(self, **kwargs):
19
+ self.generate_calls.append(kwargs)
20
+ return [np.zeros(24000, dtype=np.float32)]
21
+
22
+
23
+ def test_omnivoice_adapter_uses_stable_generation_config_for_designed_voice(tmp_path: Path) -> None:
24
+ adapter = OmniVoiceAdapter()
25
+ fake_model = FakeModel()
26
+ adapter._model = fake_model
27
+ adapter._backend = "omnivoice"
28
+
29
+ adapter.synthesize(
30
+ text="Chapter one text.",
31
+ output_path=tmp_path / "chapter.wav",
32
+ voice_config=VoiceConfig(mode="design", design_prompt="male, elderly, low pitch, british accent"),
33
+ diffusion_steps=32,
34
+ speed=1.0,
35
+ )
36
+
37
+ generation_config = fake_model.generate_calls[0]["generation_config"]
38
+ assert generation_config.position_temperature == 0.0
39
+ assert generation_config.class_temperature == 0.0
40
+
41
+
42
+ def test_omnivoice_adapter_reuses_cached_clone_prompt_across_chapters(tmp_path: Path) -> None:
43
+ adapter = OmniVoiceAdapter()
44
+ fake_model = FakeModel()
45
+ adapter._model = fake_model
46
+ adapter._backend = "omnivoice"
47
+ voice = VoiceConfig(mode="clone", sample_path="/tmp/sample.wav", reference_text="Reference speech")
48
+
49
+ adapter.synthesize(
50
+ text="Chapter one text.",
51
+ output_path=tmp_path / "chapter-1.wav",
52
+ voice_config=voice,
53
+ diffusion_steps=32,
54
+ speed=1.0,
55
+ )
56
+ adapter.synthesize(
57
+ text="Chapter two text.",
58
+ output_path=tmp_path / "chapter-2.wav",
59
+ voice_config=voice,
60
+ diffusion_steps=32,
61
+ speed=1.0,
62
+ )
63
+
64
+ assert len(fake_model.clone_prompt_calls) == 1
65
+ assert fake_model.generate_calls[0]["voice_clone_prompt"] == {"prompt": "cached"}
66
+ assert fake_model.generate_calls[1]["voice_clone_prompt"] == {"prompt": "cached"}
67
+ assert "ref_audio" not in fake_model.generate_calls[0]
68
+ assert "ref_audio" not in fake_model.generate_calls[1]
tests/test_render_api.py CHANGED
@@ -22,7 +22,7 @@ def test_start_render_api_adds_public_audio_url_to_completed_chapters(monkeypatc
22
  sys.modules.pop("app", None)
23
  module = importlib.import_module("app")
24
 
25
- class FakePipeline:
26
  def get_job(self, session_id):
27
  return SimpleNamespace(status="idle")
28
 
@@ -34,14 +34,18 @@ def test_start_render_api_adds_public_audio_url_to_completed_chapters(monkeypatc
34
  "duration_seconds": 12,
35
  "overall_progress": 0.5,
36
  "output_path": "/tmp/scriptorium/session-a/renders/001-c1.wav",
 
 
37
  }
38
  yield {
39
  "type": "completed",
40
  "session_id": kwargs["session_id"],
41
  "outputs": ["/tmp/scriptorium/session-a/renders/001-c1.wav"],
 
 
42
  }
43
 
44
- monkeypatch.setattr(module, "pipeline", FakePipeline())
45
  monkeypatch.setattr(module, "_selected_book", lambda session_id, selected_ids: {"chapters": [{"id": "c1", "included": True}]})
46
  monkeypatch.setattr(module, "_voice_config_for_backend", lambda voice_config, session_id: voice_config)
47
  monkeypatch.setattr(module.store, "save_json", lambda *args, **kwargs: None)
@@ -59,3 +63,42 @@ def test_start_render_api_adds_public_audio_url_to_completed_chapters(monkeypatc
59
  chapter_done = events[0]
60
  assert chapter_done["type"] == "chapter_done"
61
  assert chapter_done["url"] == "/files/session-a/renders/001-c1.wav"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  sys.modules.pop("app", None)
23
  module = importlib.import_module("app")
24
 
25
+ class FakeSynthesisService:
26
  def get_job(self, session_id):
27
  return SimpleNamespace(status="idle")
28
 
 
34
  "duration_seconds": 12,
35
  "overall_progress": 0.5,
36
  "output_path": "/tmp/scriptorium/session-a/renders/001-c1.wav",
37
+ "backend": "modal",
38
+ "model": "magpie",
39
  }
40
  yield {
41
  "type": "completed",
42
  "session_id": kwargs["session_id"],
43
  "outputs": ["/tmp/scriptorium/session-a/renders/001-c1.wav"],
44
+ "backend": "modal",
45
+ "model": "magpie",
46
  }
47
 
48
+ monkeypatch.setattr(module, "synthesis_service", FakeSynthesisService())
49
  monkeypatch.setattr(module, "_selected_book", lambda session_id, selected_ids: {"chapters": [{"id": "c1", "included": True}]})
50
  monkeypatch.setattr(module, "_voice_config_for_backend", lambda voice_config, session_id: voice_config)
51
  monkeypatch.setattr(module.store, "save_json", lambda *args, **kwargs: None)
 
63
  chapter_done = events[0]
64
  assert chapter_done["type"] == "chapter_done"
65
  assert chapter_done["url"] == "/files/session-a/renders/001-c1.wav"
66
+ assert chapter_done["backend"] == "modal"
67
+ assert chapter_done["model"] == "magpie"
68
+
69
+
70
+ def test_generate_preview_api_exposes_model_and_backend(monkeypatch) -> None:
71
+ sys.modules.pop("app", None)
72
+ module = importlib.import_module("app")
73
+
74
+ class FakeSynthesisService:
75
+ def generate_preview(self, **kwargs):
76
+ output_path = kwargs["output_path"]
77
+ output_path.parent.mkdir(parents=True, exist_ok=True)
78
+ output_path.write_bytes(b"RIFFpreview")
79
+ return {
80
+ "duration_seconds": 6,
81
+ "sample_rate": 24000,
82
+ "backend": "modal",
83
+ "model": "magpie",
84
+ }
85
+
86
+ monkeypatch.setattr(module, "synthesis_service", FakeSynthesisService())
87
+ monkeypatch.setattr(
88
+ module,
89
+ "_book_payload",
90
+ lambda _session_id: {"chapters": [{"id": "c1", "text": "hello world", "title": "One"}]},
91
+ )
92
+ monkeypatch.setattr(module, "_voice_config_for_backend", lambda voice_config, session_id: voice_config)
93
+
94
+ payload = module.generate_preview_api(
95
+ session_id="session-a",
96
+ chapter_id="c1",
97
+ voice_config={"model": "magpie", "backend": "modal", "speaker": "Sofia", "language": "en"},
98
+ diffusion_steps=32,
99
+ speed=1.0,
100
+ )
101
+
102
+ assert payload["url"] == "/files/session-a/previews/c1.wav"
103
+ assert payload["backend"] == "modal"
104
+ assert payload["model"] == "magpie"
tests/test_render_pipeline.py CHANGED
@@ -10,7 +10,12 @@ class FakeSynthesizer:
10
  def synthesize(self, *, text: str, output_path: Path, **kwargs) -> dict:
11
  self.calls.append({"text": text, "output_path": output_path, **kwargs})
12
  output_path.write_bytes(b"RIFFfakewave")
13
- return {"duration_seconds": max(1, len(text.split()) // 2), "sample_rate": 24000}
 
 
 
 
 
14
 
15
 
16
  def _chapters() -> list:
@@ -41,6 +46,8 @@ def test_render_pipeline_streams_ordered_progress_events(tmp_path: Path) -> None
41
  assert "chapter_done" in event_types
42
  assert event_types[-1] == "completed"
43
  assert len(synthesizer.calls) == 2
 
 
44
 
45
 
46
  def test_render_pipeline_can_be_cancelled(tmp_path: Path) -> None:
 
10
  def synthesize(self, *, text: str, output_path: Path, **kwargs) -> dict:
11
  self.calls.append({"text": text, "output_path": output_path, **kwargs})
12
  output_path.write_bytes(b"RIFFfakewave")
13
+ return {
14
+ "duration_seconds": max(1, len(text.split()) // 2),
15
+ "sample_rate": 24000,
16
+ "backend": "local",
17
+ "model": kwargs["voice_config"].model,
18
+ }
19
 
20
 
21
  def _chapters() -> list:
 
46
  assert "chapter_done" in event_types
47
  assert event_types[-1] == "completed"
48
  assert len(synthesizer.calls) == 2
49
+ assert all(event["backend"] == "local" for event in events)
50
+ assert all(event["model"] == "omnivoice" for event in events)
51
 
52
 
53
  def test_render_pipeline_can_be_cancelled(tmp_path: Path) -> None:
tests/test_synthesis_service.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from backend.synthesis_service import SynthesisService
6
+ from backend.types import VoiceConfig
7
+
8
+
9
+ class FakeSynthesizer:
10
+ def __init__(self, model: str) -> None:
11
+ self.model = model
12
+ self.calls = []
13
+
14
+ def synthesize(self, *, text: str, output_path: Path, voice_config: VoiceConfig, **kwargs) -> dict:
15
+ self.calls.append(
16
+ {
17
+ "text": text,
18
+ "output_path": output_path,
19
+ "voice_config": voice_config,
20
+ **kwargs,
21
+ }
22
+ )
23
+ output_path.parent.mkdir(parents=True, exist_ok=True)
24
+ output_path.write_bytes(b"RIFFfakewave")
25
+ return {
26
+ "duration_seconds": 4,
27
+ "sample_rate": 24000,
28
+ "backend": "local",
29
+ "model": self.model,
30
+ }
31
+
32
+
33
+ class FakeModalClient:
34
+ def __init__(self) -> None:
35
+ self.preview_calls = []
36
+ self.submit_calls = []
37
+ self.render_calls = []
38
+ self.cancelled = []
39
+
40
+ def generate_preview(self, **kwargs) -> dict:
41
+ self.preview_calls.append(kwargs)
42
+ output_path = kwargs["output_path"]
43
+ output_path.parent.mkdir(parents=True, exist_ok=True)
44
+ output_path.write_bytes(b"RIFFmodalpreview")
45
+ return {
46
+ "duration_seconds": 5,
47
+ "sample_rate": 24000,
48
+ "backend": "modal",
49
+ "model": kwargs["voice_config"].model,
50
+ }
51
+
52
+ def submit_render(self, **kwargs) -> str:
53
+ self.submit_calls.append(kwargs)
54
+ return "job-123"
55
+
56
+ def render(self, **kwargs):
57
+ self.render_calls.append(kwargs)
58
+ output_path = kwargs["render_dir"] / "001-c1.wav"
59
+ output_path.parent.mkdir(parents=True, exist_ok=True)
60
+ output_path.write_bytes(b"RIFFmodalrender")
61
+ yield {
62
+ "type": "started",
63
+ "session_id": kwargs["session_id"],
64
+ "total_chapters": 1,
65
+ "book_title": "Book",
66
+ "backend": "modal",
67
+ "model": kwargs["voice_config"].model,
68
+ }
69
+ yield {
70
+ "type": "chapter_done",
71
+ "session_id": kwargs["session_id"],
72
+ "chapter_id": "c1",
73
+ "duration_seconds": 7,
74
+ "overall_progress": 1.0,
75
+ "output_path": str(output_path),
76
+ "backend": "modal",
77
+ "model": kwargs["voice_config"].model,
78
+ }
79
+ yield {
80
+ "type": "completed",
81
+ "session_id": kwargs["session_id"],
82
+ "outputs": [str(output_path)],
83
+ "backend": "modal",
84
+ "model": kwargs["voice_config"].model,
85
+ }
86
+
87
+ def cancel(self, session_id: str) -> dict:
88
+ self.cancelled.append(session_id)
89
+ return {"type": "cancelled", "session_id": session_id, "backend": "modal"}
90
+
91
+
92
+ def test_preview_routes_to_model_specific_local_adapter(tmp_path: Path) -> None:
93
+ omnivoice = FakeSynthesizer("omnivoice")
94
+ magpie = FakeSynthesizer("magpie")
95
+ service = SynthesisService(
96
+ session_root=tmp_path,
97
+ local_synthesizers={"omnivoice": omnivoice, "magpie": magpie},
98
+ modal_client=FakeModalClient(),
99
+ )
100
+
101
+ result = service.generate_preview(
102
+ text="Hello from Magpie.",
103
+ output_path=tmp_path / "previews" / "preview.wav",
104
+ voice_config=VoiceConfig(model="magpie", backend="local", speaker="Sofia", language="en"),
105
+ diffusion_steps=32,
106
+ speed=1.0,
107
+ )
108
+
109
+ assert result["backend"] == "local"
110
+ assert result["model"] == "magpie"
111
+ assert len(magpie.calls) == 1
112
+ assert magpie.calls[0]["voice_config"].speaker == "Sofia"
113
+ assert not omnivoice.calls
114
+
115
+
116
+ def test_render_routes_modal_jobs_and_preserves_event_shape(tmp_path: Path) -> None:
117
+ service = SynthesisService(
118
+ session_root=tmp_path,
119
+ local_synthesizers={"omnivoice": FakeSynthesizer("omnivoice"), "magpie": FakeSynthesizer("magpie")},
120
+ modal_client=FakeModalClient(),
121
+ )
122
+
123
+ events = list(
124
+ service.render(
125
+ session_id="session-a",
126
+ book={"title": "Book"},
127
+ chapters=[{"id": "c1", "title": "One", "text": "hello world", "included": True}],
128
+ voice_config={"model": "magpie", "backend": "modal", "speaker": "Sofia", "language": "en"},
129
+ diffusion_steps=32,
130
+ speed=1.0,
131
+ )
132
+ )
133
+
134
+ assert [event["type"] for event in events] == ["started", "chapter_done", "completed"]
135
+ assert all(event["backend"] == "modal" for event in events)
136
+ assert all(event["model"] == "magpie" for event in events)
137
+ assert Path(events[1]["output_path"]).exists()
138
+
139
+
140
+ def test_pause_and_resume_are_rejected_for_modal_jobs(tmp_path: Path) -> None:
141
+ service = SynthesisService(
142
+ session_root=tmp_path,
143
+ local_synthesizers={"omnivoice": FakeSynthesizer("omnivoice"), "magpie": FakeSynthesizer("magpie")},
144
+ modal_client=FakeModalClient(),
145
+ )
146
+ service._active_backends["session-a"] = "modal"
147
+
148
+ with pytest.raises(ValueError, match="Pause is only supported for local renders"):
149
+ service.pause("session-a")
150
+
151
+ with pytest.raises(ValueError, match="Resume is only supported for local renders"):
152
+ service.resume("session-a")
tests/test_types.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.types import VoiceConfig
2
+
3
+
4
+ def test_voice_config_from_dict_parses_model_backend_and_magpie_fields() -> None:
5
+ voice = VoiceConfig.from_dict(
6
+ {
7
+ "model": "magpie",
8
+ "backend": "modal",
9
+ "mode": "auto",
10
+ "speaker": "Sofia",
11
+ "language": "en",
12
+ "applyTextNormalization": True,
13
+ }
14
+ )
15
+
16
+ assert voice.model == "magpie"
17
+ assert voice.backend == "modal"
18
+ assert voice.mode == "auto"
19
+ assert voice.speaker == "Sofia"
20
+ assert voice.language == "en"
21
+ assert voice.apply_text_normalization is True
tests/test_voice_presets.py CHANGED
@@ -60,6 +60,8 @@ def test_homepage_injects_voice_presets_for_frontend() -> None:
60
 
61
  assert "window.__VOICE_PRESETS__" in html
62
  assert "window.__VOICE_DESIGN_OPTIONS__" in html
 
 
63
 
64
 
65
  def test_voice_design_options_use_only_supported_omnivoice_design_tokens() -> None:
 
60
 
61
  assert "window.__VOICE_PRESETS__" in html
62
  assert "window.__VOICE_DESIGN_OPTIONS__" in html
63
+ assert "window.__MAGPIE_OPTIONS__" in html
64
+ assert "window.__SYNTHESIS_BACKENDS__" in html
65
 
66
 
67
  def test_voice_design_options_use_only_supported_omnivoice_design_tokens() -> None: