Codex commited on
Commit ·
1f36bcf
1
Parent(s): 4576e13
Fix Modal deployment and Magpie runtime integration
Browse files- app.py +8 -1
- backend/magpie_adapter.py +24 -50
- backend/modal_client.py +34 -18
- modal_app.py +3 -1
- requirements.txt +2 -0
- tests/test_app_startup.py +17 -0
- tests/test_magpie_adapter.py +30 -0
- tests/test_magpie_dependencies.py +15 -0
- tests/test_modal_app_file.py +15 -0
- tests/test_modal_client.py +36 -0
app.py
CHANGED
|
@@ -40,9 +40,16 @@ synthesis_service = SynthesisService(session_root=TEMP_ROOT)
|
|
| 40 |
|
| 41 |
|
| 42 |
def _warn_about_modal_configuration() -> None:
|
| 43 |
-
|
|
|
|
| 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 |
-
|
| 31 |
-
|
| 32 |
-
|
| 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 = "
|
|
|
|
| 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 = "
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 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=
|
| 125 |
)
|
| 126 |
-
waveform = audio[0, : audio_len[0]].detach().cpu().numpy()
|
| 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 =
|
| 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", "
|
| 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 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
def _get_json(self, path: str, *, params: Dict[str, object]) -> Dict[str, object]:
|
| 176 |
self._ensure_configured()
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 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 |
+
)
|