Codex commited on
Commit
1f36bcf
·
1 Parent(s): 4576e13

Fix Modal deployment and Magpie runtime integration

Browse files
app.py CHANGED
@@ -40,9 +40,16 @@ synthesis_service = SynthesisService(session_root=TEMP_ROOT)
40
 
41
 
42
  def _warn_about_modal_configuration() -> None:
43
- warning = synthesis_service.modal_client.configuration_warning()
 
44
  if warning:
45
  print(f"Modal configuration warning: {warning}")
 
 
 
 
 
 
46
 
47
 
48
  _warn_about_modal_configuration()
 
40
 
41
 
42
  def _warn_about_modal_configuration() -> None:
43
+ client = synthesis_service.modal_client
44
+ warning = client.configuration_warning()
45
  if warning:
46
  print(f"Modal configuration warning: {warning}")
47
+ return
48
+ if client.is_configured():
49
+ print(
50
+ "Modal backend configured: "
51
+ f"base_url={client.base_url} timeout={client.timeout_seconds}s poll_interval={client.poll_interval_seconds}s"
52
+ )
53
 
54
 
55
  _warn_about_modal_configuration()
backend/magpie_adapter.py CHANGED
@@ -1,10 +1,7 @@
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
@@ -25,12 +22,12 @@ except ImportError:
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
 
@@ -46,7 +43,8 @@ class MagpieAdapter:
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
@@ -66,8 +64,9 @@ class MagpieAdapter:
66
  ModelLoadConfig,
67
  load_magpie_model,
68
  )
69
- except Exception:
70
- self._engine = "fallback"
 
71
  return None
72
 
73
  config = ModelLoadConfig(
@@ -83,8 +82,14 @@ class MagpieAdapter:
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,
@@ -99,19 +104,18 @@ class MagpieAdapter:
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()
@@ -121,9 +125,9 @@ class MagpieAdapter:
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))
@@ -134,33 +138,3 @@ class MagpieAdapter:
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
- }
 
 
 
1
  import os
2
  from pathlib import Path
3
  from typing import Dict, Optional
4
 
 
5
  import soundfile as sf
6
 
7
  from backend.synthesis_catalog import MAGPIE_LANGUAGES, MAGPIE_MODEL, MAGPIE_SPEAKERS
 
22
 
23
  spaces = _SpacesShim()
24
 
 
25
  MAGPIE_SPEAKER_IDS = {
26
+ "John": 0,
27
+ "Sofia": 1,
28
+ "Aria": 2,
29
+ "Jason": 3,
30
+ "Leo": 4,
31
  }
32
  MAGPIE_SUPPORTED_LANGUAGES = {language["value"] for language in MAGPIE_LANGUAGES}
33
 
 
43
  self.checkpoint_filename = checkpoint_filename
44
  self.codec_model_path = codec_model_path
45
  self._model = None
46
+ self._engine = "unloaded"
47
+ self._load_error: Optional[Exception] = None
48
 
49
  def _checkpoint_path(self) -> str:
50
  from huggingface_hub import hf_hub_download
 
64
  ModelLoadConfig,
65
  load_magpie_model,
66
  )
67
+ except Exception as exc:
68
+ self._engine = "load_failed"
69
+ self._load_error = exc
70
  return None
71
 
72
  config = ModelLoadConfig(
 
82
  model.cuda()
83
  self._model = model
84
  self._engine = "magpie"
85
+ self._load_error = None
86
  return self._model
87
 
88
+ def speaker_index_for(self, speaker: str) -> int:
89
+ if speaker not in MAGPIE_SPEAKER_IDS:
90
+ raise ValueError(f"Unsupported Magpie speaker: {speaker}")
91
+ return MAGPIE_SPEAKER_IDS[speaker]
92
+
93
  @spaces.GPU(duration=300)
94
  def synthesize(
95
  self,
 
104
  del diffusion_steps, speed
105
  output_path.parent.mkdir(parents=True, exist_ok=True)
106
  speaker = voice_config.speaker or "Sofia"
107
+ speaker_index = self.speaker_index_for(speaker)
 
108
  target_language = voice_config.language or language or "en"
109
  if target_language not in MAGPIE_SUPPORTED_LANGUAGES:
110
  raise ValueError(f"Unsupported Magpie language: {target_language}")
111
 
112
  model = self._load_model()
113
  if model is None:
114
+ detail = f"{type(self._load_error).__name__}: {self._load_error}" if self._load_error else "unknown error"
115
+ raise ValueError(
116
+ "Magpie TTS runtime is unavailable. "
117
+ "This app must install NVIDIA NeMo Magpie dependencies to synthesize real speech. "
118
+ f"Model load failed with {detail}."
119
  )
120
 
121
  cleaned_text = text.strip()
 
125
  cleaned_text,
126
  language=target_language,
127
  apply_TN=voice_config.apply_text_normalization,
128
+ speaker_index=speaker_index,
129
  )
130
+ waveform = audio[0, : audio_len[0]].detach().cpu().numpy()
131
  sample_rate = int(getattr(model, "sample_rate", 22050))
132
  sf.write(str(output_path), waveform, sample_rate)
133
  duration_seconds = int(round(len(waveform) / sample_rate))
 
138
  "model": MAGPIE_MODEL,
139
  "engine": self._engine,
140
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/modal_client.py CHANGED
@@ -17,7 +17,7 @@ class ModalSynthesisClient:
17
  *,
18
  base_url: Optional[str],
19
  auth_token: Optional[str] = None,
20
- timeout_seconds: float = 60.0,
21
  poll_interval_seconds: float = 1.0,
22
  ) -> None:
23
  self.base_url = base_url.rstrip("/") if base_url else None
@@ -30,7 +30,7 @@ class ModalSynthesisClient:
30
  return cls(
31
  base_url=os.getenv("SCRIPTORIUM_MODAL_BASE_URL"),
32
  auth_token=os.getenv("SCRIPTORIUM_MODAL_AUTH_TOKEN"),
33
- timeout_seconds=float(os.getenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", "60")),
34
  poll_interval_seconds=float(os.getenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", "1")),
35
  )
36
 
@@ -163,25 +163,41 @@ class ModalSynthesisClient:
163
 
164
  def _post_json(self, path: str, payload: Dict[str, object]) -> Dict[str, object]:
165
  self._ensure_configured()
166
- response = requests.post(
167
- self._absolute_url(path),
168
- json=payload,
169
- headers=self._headers(),
170
- timeout=self.timeout_seconds,
171
- )
172
- response.raise_for_status()
173
- return response.json()
 
 
 
 
 
 
 
 
174
 
175
  def _get_json(self, path: str, *, params: Dict[str, object]) -> Dict[str, object]:
176
  self._ensure_configured()
177
- response = requests.get(
178
- self._absolute_url(path),
179
- params=params,
180
- headers=self._headers(),
181
- timeout=self.timeout_seconds,
182
- )
183
- response.raise_for_status()
184
- return response.json()
 
 
 
 
 
 
 
 
185
 
186
  def _absolute_url(self, path: str) -> str:
187
  if path.startswith("http://") or path.startswith("https://"):
 
17
  *,
18
  base_url: Optional[str],
19
  auth_token: Optional[str] = None,
20
+ timeout_seconds: float = 300.0,
21
  poll_interval_seconds: float = 1.0,
22
  ) -> None:
23
  self.base_url = base_url.rstrip("/") if base_url else None
 
30
  return cls(
31
  base_url=os.getenv("SCRIPTORIUM_MODAL_BASE_URL"),
32
  auth_token=os.getenv("SCRIPTORIUM_MODAL_AUTH_TOKEN"),
33
+ timeout_seconds=float(os.getenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", "300")),
34
  poll_interval_seconds=float(os.getenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", "1")),
35
  )
36
 
 
163
 
164
  def _post_json(self, path: str, payload: Dict[str, object]) -> Dict[str, object]:
165
  self._ensure_configured()
166
+ try:
167
+ response = requests.post(
168
+ self._absolute_url(path),
169
+ json=payload,
170
+ headers=self._headers(),
171
+ timeout=self.timeout_seconds,
172
+ )
173
+ response.raise_for_status()
174
+ return response.json()
175
+ except requests.Timeout as exc:
176
+ raise ValueError(
177
+ "Modal request timed out. The remote worker may still be cold-starting or loading models. "
178
+ "Try again, or increase SCRIPTORIUM_MODAL_TIMEOUT_SECONDS."
179
+ ) from exc
180
+ except requests.RequestException as exc:
181
+ raise ValueError(f"Modal request failed: {exc}") from exc
182
 
183
  def _get_json(self, path: str, *, params: Dict[str, object]) -> Dict[str, object]:
184
  self._ensure_configured()
185
+ try:
186
+ response = requests.get(
187
+ self._absolute_url(path),
188
+ params=params,
189
+ headers=self._headers(),
190
+ timeout=self.timeout_seconds,
191
+ )
192
+ response.raise_for_status()
193
+ return response.json()
194
+ except requests.Timeout as exc:
195
+ raise ValueError(
196
+ "Modal request timed out. The remote worker may still be cold-starting or loading models. "
197
+ "Try again, or increase SCRIPTORIUM_MODAL_TIMEOUT_SECONDS."
198
+ ) from exc
199
+ except requests.RequestException as exc:
200
+ raise ValueError(f"Modal request failed: {exc}") from exc
201
 
202
  def _absolute_url(self, path: str) -> str:
203
  if path.startswith("http://") or path.startswith("https://"):
modal_app.py CHANGED
@@ -19,6 +19,7 @@ from backend.types import VoiceConfig
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",
@@ -28,9 +29,10 @@ image = (
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)
 
19
  app = modal.App("scriptorium-tts")
20
  image = (
21
  modal.Image.debian_slim(python_version="3.12")
22
+ .apt_install("git")
23
  .pip_install(
24
  "fastapi[standard]",
25
  "numpy>=1.26.0",
 
29
  "omnivoice>=0.1.5",
30
  "requests>=2.32.0",
31
  "huggingface_hub>=0.33.0",
32
+ "nemo_toolkit[tts]@git+https://github.com/NVIDIA/NeMo.git@main",
33
  "kaldialign",
34
  )
35
+ .add_local_python_source("backend")
36
  )
37
  jobs = modal.Dict.from_name("scriptorium-modal-jobs", create_if_missing=True)
38
  artifacts = modal.Dict.from_name("scriptorium-modal-artifacts", create_if_missing=True)
requirements.txt CHANGED
@@ -10,3 +10,5 @@ torchaudio>=2.8.0
10
  omnivoice>=0.1.5
11
  requests>=2.32.0
12
  huggingface_hub>=0.33.0
 
 
 
10
  omnivoice>=0.1.5
11
  requests>=2.32.0
12
  huggingface_hub>=0.33.0
13
+ nemo_toolkit[tts]@git+https://github.com/NVIDIA/NeMo.git@main
14
+ kaldialign
tests/test_app_startup.py CHANGED
@@ -48,3 +48,20 @@ def test_app_import_warns_for_modal_dashboard_url(monkeypatch) -> None:
48
 
49
  assert any("SCRIPTORIUM_MODAL_BASE_URL" in line for line in printed)
50
  assert any("modal.run" in line for line in printed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  assert any("SCRIPTORIUM_MODAL_BASE_URL" in line for line in printed)
50
  assert any("modal.run" in line for line in printed)
51
+
52
+
53
+ def test_app_import_reports_modal_runtime_configuration(monkeypatch) -> None:
54
+ sys.modules.pop("app", None)
55
+ printed = []
56
+
57
+ monkeypatch.setenv(
58
+ "SCRIPTORIUM_MODAL_BASE_URL",
59
+ "https://scriptorium-tts--mattkevan.modal.run",
60
+ )
61
+ monkeypatch.setenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", "300")
62
+ monkeypatch.setattr("builtins.print", lambda *args, **kwargs: printed.append(" ".join(str(arg) for arg in args)))
63
+
64
+ importlib.import_module("app")
65
+
66
+ assert any("Modal backend configured" in line for line in printed)
67
+ assert any("timeout=300.0s" in line for line in printed)
tests/test_magpie_adapter.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import pytest
4
+
5
+ from backend.magpie_adapter import MagpieAdapter
6
+ from backend.types import VoiceConfig
7
+
8
+
9
+ def test_magpie_adapter_raises_when_runtime_is_unavailable(tmp_path: Path) -> None:
10
+ adapter = MagpieAdapter()
11
+ adapter._load_error = ModuleNotFoundError("No module named 'nemo'")
12
+
13
+ with pytest.raises(ValueError, match="Magpie TTS runtime is unavailable"):
14
+ adapter.synthesize(
15
+ text="Hello from Magpie.",
16
+ output_path=tmp_path / "magpie.wav",
17
+ voice_config=VoiceConfig(model="magpie", speaker="Sofia", language="en"),
18
+ diffusion_steps=32,
19
+ speed=1.0,
20
+ )
21
+
22
+
23
+ def test_magpie_adapter_uses_official_speaker_mapping() -> None:
24
+ adapter = MagpieAdapter()
25
+
26
+ assert adapter.speaker_index_for("John") == 0
27
+ assert adapter.speaker_index_for("Sofia") == 1
28
+ assert adapter.speaker_index_for("Aria") == 2
29
+ assert adapter.speaker_index_for("Jason") == 3
30
+ assert adapter.speaker_index_for("Leo") == 4
tests/test_magpie_dependencies.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+
4
+ def test_requirements_include_official_magpie_runtime_dependencies() -> None:
5
+ requirements = Path("requirements.txt").read_text(encoding="utf-8")
6
+
7
+ assert "nemo_toolkit[tts]@git+https://github.com/NVIDIA/NeMo.git@main" in requirements
8
+ assert "kaldialign" in requirements
9
+
10
+
11
+ def test_modal_app_uses_official_magpie_runtime_dependencies() -> None:
12
+ source = Path("modal_app.py").read_text(encoding="utf-8")
13
+
14
+ assert '"nemo_toolkit[tts]@git+https://github.com/NVIDIA/NeMo.git@main"' in source
15
+ assert '"kaldialign"' in source
tests/test_modal_app_file.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+
4
+ def test_modal_app_includes_backend_python_source_in_image() -> None:
5
+ source = Path("modal_app.py").read_text(encoding="utf-8")
6
+
7
+ assert '.add_local_python_source("backend")' in source
8
+ assert source.index(".pip_install(") < source.index('.add_local_python_source("backend")')
9
+
10
+
11
+ def test_modal_app_installs_git_before_git_based_pip_dependencies() -> None:
12
+ source = Path("modal_app.py").read_text(encoding="utf-8")
13
+
14
+ assert '.apt_install("git")' in source
15
+ assert source.index('.apt_install("git")') < source.index(".pip_install(")
tests/test_modal_client.py CHANGED
@@ -1,4 +1,6 @@
1
  from backend.modal_client import ModalSynthesisClient
 
 
2
 
3
 
4
  def test_modal_client_warns_when_configured_with_dashboard_url() -> None:
@@ -15,3 +17,37 @@ def test_modal_client_accepts_modal_run_base_url() -> None:
15
  client = ModalSynthesisClient(base_url="https://scriptorium-tts--mattkevan.modal.run")
16
 
17
  assert client.configuration_warning() is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from backend.modal_client import ModalSynthesisClient
2
+ import pytest
3
+ import requests
4
 
5
 
6
  def test_modal_client_warns_when_configured_with_dashboard_url() -> None:
 
17
  client = ModalSynthesisClient(base_url="https://scriptorium-tts--mattkevan.modal.run")
18
 
19
  assert client.configuration_warning() is None
20
+
21
+
22
+ def test_modal_client_defaults_to_longer_timeout() -> None:
23
+ client = ModalSynthesisClient(base_url="https://scriptorium-tts--mattkevan.modal.run")
24
+
25
+ assert client.timeout_seconds == 300.0
26
+
27
+
28
+ def test_modal_client_uses_longer_default_timeout_from_env(monkeypatch) -> None:
29
+ monkeypatch.delenv("SCRIPTORIUM_MODAL_TIMEOUT_SECONDS", raising=False)
30
+ monkeypatch.delenv("SCRIPTORIUM_MODAL_POLL_INTERVAL_SECONDS", raising=False)
31
+
32
+ client = ModalSynthesisClient.from_env()
33
+
34
+ assert client.timeout_seconds == 300.0
35
+ assert client.poll_interval_seconds == 1.0
36
+
37
+
38
+ def test_modal_client_surfaces_read_timeout_as_user_facing_error(monkeypatch, tmp_path) -> None:
39
+ client = ModalSynthesisClient(base_url="https://scriptorium-tts--mattkevan.modal.run", timeout_seconds=1)
40
+
41
+ def fake_post(*args, **kwargs):
42
+ raise requests.ReadTimeout("timed out")
43
+
44
+ monkeypatch.setattr("backend.modal_client.requests.post", fake_post)
45
+
46
+ with pytest.raises(ValueError, match="Modal request timed out"):
47
+ client.generate_preview(
48
+ text="Hello",
49
+ output_path=tmp_path / "preview.wav",
50
+ voice_config=type("Voice", (), {"to_dict": lambda self: {}, "model": "omnivoice"})(),
51
+ diffusion_steps=32,
52
+ speed=1.0,
53
+ )