diff options
| author | historia <historiavg@proton.me> | 2026-09-04 17:41:43 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-04 17:41:43 -0400 |
| commit | 0157ce4a347f9625e1e9d09e2bbf0fbfad722557 (patch) | |
| tree | a2239553d7e8ac5cdb5931ad49482487e1f49905 | |
| parent | 5263a30356d7a7b39490e9a3cf5f6c179249500c (diff) | |
| download | tts-audiobook-generator-main.tar.gz | |
| -rw-r--r-- | app/backends/sglomni/__init__.py | 3 | ||||
| -rw-r--r-- | app/backends/sglomni/catalog.py | 13 | ||||
| -rw-r--r-- | app/backends/sglomni/constants.py | 3 | ||||
| -rw-r--r-- | app/backends/sglomni/models.py | 87 | ||||
| -rw-r--r-- | app/backends/sglomni/pythonenv.py | 3 | ||||
| -rw-r--r-- | app/backends/sglomni/status.py | 16 | ||||
| -rw-r--r-- | app/backends/sglomni/wizard.py | 103 | ||||
| -rw-r--r-- | app/converter/clients/sglomni.py | 147 | ||||
| -rw-r--r-- | app/tests/test_backends_sglomni.py | 291 | ||||
| -rw-r--r-- | app/tests/test_tts_sglomni.py | 99 |
10 files changed, 591 insertions, 174 deletions
diff --git a/app/backends/sglomni/__init__.py b/app/backends/sglomni/__init__.py index be94a0e..ed7d4ed 100644 --- a/app/backends/sglomni/__init__.py +++ b/app/backends/sglomni/__init__.py @@ -22,7 +22,6 @@ from .constants import ( SERVER_NAME, SERVER_START_TIMEOUT, SGLOMNI_PIP_PKG, - UV_PIP_PKG, ) from .gpu import ( compute_capability, @@ -79,7 +78,7 @@ from .wizard import ( __all__ = [ # constants "CONFIGS_DIR", "DEFAULT_PORT", "PYTHON_SPEC", "PYTHON_VERSIONS", - "SERVER_NAME", "SERVER_START_TIMEOUT", "SGLOMNI_PIP_PKG", "UV_PIP_PKG", + "SERVER_NAME", "SERVER_START_TIMEOUT", "SGLOMNI_PIP_PKG", # gpu "compute_capability", "describe", # catalog diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py index f20509f..8bfb1fa 100644 --- a/app/backends/sglomni/catalog.py +++ b/app/backends/sglomni/catalog.py @@ -52,6 +52,11 @@ class ModelEntry: system_hint: Optional[str] = None # remediation when the binary is absent speakers: Optional[Tuple[str, ...]] = None # preset voices (speaker) supports_seed: bool = False # request-scoped seed accepted (Qwen3-TTS Base) + # NOTE(unverified upstream): only the two Base entries are known to + # accept a request-scoped seed (Voxtral rejects one outright); qwen's + # demo client does send seeds to the CustomVoice/VoiceDesign models, + # so those pipelines may accept one too — verify before flipping the + # flag (CONSTANT_SEED currently no-ops for every other entry). notes: str = "" # one-line description (documentation) # The model's DEFAULT pipeline dynamically quantizes its MoE experts to # FP8 at load time (sglang-omni's zonos2 config hardcodes it) — a Triton @@ -103,6 +108,12 @@ _QWEN_EXTRAS: Tuple[Extra, ...] = ( ("sox", True), ("einops", True), ("qwen-tts==0.1.1", True)) _SOX_HINT = ("install the sox system package (e.g. sudo pacman -S sox, " "sudo apt install sox, brew install sox)") +# The Fish Audio and ZONOS2 pipelines decode their codec assets through +# the ffmpeg binary (and the client concatenates multi-part chunks with +# it); the weights download fine without it, the server just fails to +# synthesize — so the install flow warns, like it does for sox. +_FFMPEG_HINT = ("install the ffmpeg system package (e.g. sudo pacman -S " + "ffmpeg, sudo apt install ffmpeg, brew install ffmpeg)") # The Fish Audio and ZONOS2 pipelines use the Descript DAC codec, which # upstream installs WITH dependencies — but descript-audiotools carries a # vestigial 2021-era pin, protobuf<3.20 (its code never imports protobuf), @@ -255,6 +266,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( capability=CAPABILITY_CLONE, requires_reference=False, extras=_DAC_EXTRAS, + system_dep="ffmpeg", system_hint=_FFMPEG_HINT, notes="zero-shot narration or cloning from a reference clip", ), ModelEntry( @@ -265,6 +277,7 @@ ENTRIES: Tuple[ModelEntry, ...] = ( capability=CAPABILITY_CLONE, requires_reference=True, extras=_DAC_EXTRAS, + system_dep="ffmpeg", system_hint=_FFMPEG_HINT, notes="voice cloning, 44.1 kHz DAC vocoder", fp8_moe=True, fp8_min_compute_capability=(8, 9), diff --git a/app/backends/sglomni/constants.py b/app/backends/sglomni/constants.py index 94a07bd..945ce8a 100644 --- a/app/backends/sglomni/constants.py +++ b/app/backends/sglomni/constants.py @@ -6,9 +6,6 @@ from pathlib import Path # (like qwen-tts and faster-qwen3-tts) so the update action can move with # upstream releases; the stack this code was verified against is 0.1.4. SGLOMNI_PIP_PKG = "sglang-omni" -# uv is provisioned into the app env only when a Python the sglang-omni -# stack accepts (>=3.10,<3.13) is not already available. -UV_PIP_PKG = "uv" # The dedicated venv is backends.envs.SGLOMNI_ENV_DIR (imported from there # by the modules that need it) — its interpreter may differ from the diff --git a/app/backends/sglomni/models.py b/app/backends/sglomni/models.py index 607e36a..990673a 100644 --- a/app/backends/sglomni/models.py +++ b/app/backends/sglomni/models.py @@ -16,31 +16,25 @@ VoiceDesign exist in the qwen backend too) is downloaded once and its deletion affects both — the same convention every backend here accepts. """ -import os import shutil from pathlib import Path from typing import List, Optional from backends import common, envs -from backends.sglomni.catalog import ModelEntry, entry_by_key, \ - entry_by_repo, extra_import_name +from backends.sglomni.catalog import ENTRIES, Extra, ModelEntry, \ + entry_by_key, entry_by_repo, extra_import_name from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env -# The cache directory HF keeps repos in (models--<org>--<name> folders). -# Resolution mirrors huggingface_hub.constants: HF_HUB_CACHE beats -# HUGGINGFACE_HUB_CACHE beats HF_HOME/hub beats ~/.cache/huggingface/hub. +# The HF-cache primitives live in backends.common (shared with the qwen +# backend, which fetches into the same cache); the module-level wrappers +# below keep the names internal callers (and the tests) reference. def _hf_cache_dir() -> Path: - override = os.environ.get("HF_HUB_CACHE") or os.environ.get( - "HUGGINGFACE_HUB_CACHE") - if override: - return Path(override) - home = os.environ.get("HF_HOME") - if home: - return Path(home) / "hub" - return Path.home() / ".cache" / "huggingface" / "hub" + """The cache directory HF keeps repos in (models--<org>--<name> + folders); common.hf_cache_dir resolves the environment overrides.""" + return common.hf_cache_dir() def repo_dir(repo_id: str) -> Path: @@ -55,17 +49,7 @@ def model_repo_dir(entry: ModelEntry) -> Path: def _tree_has_file(path: Path) -> bool: """True when any file or symlink exists under PATH (recursively).""" - try: - for item in path.iterdir(): - # Snapshot files are symlinks into blobs/; count them even when - # temporarily broken (presence is what the loader checks). - if item.is_symlink() or item.is_file(): - return True - if item.is_dir() and _tree_has_file(item): - return True - except OSError: - return False - return False + return common.hf_tree_has_file(path) def model_installed(entry: ModelEntry) -> bool: @@ -83,7 +67,7 @@ def model_installed(entry: ModelEntry) -> bool: def installed_entries() -> List[ModelEntry]: """The catalog entries whose weights are already on disk.""" - return [entry for entry in _all_entries() if model_installed(entry)] + return [entry for entry in ENTRIES if model_installed(entry)] def installed_keys() -> List[str]: @@ -115,11 +99,6 @@ def preset_voices(entry: ModelEntry) -> List[str]: return [] -def _all_entries() -> List[ModelEntry]: - from backends.sglomni.catalog import ENTRIES - return list(ENTRIES) - - def system_dep_missing(entry: ModelEntry) -> Optional[str]: """Remediation text when ENTRY's system binary is absent (None = ok).""" if entry.system_dep and not shutil.which(entry.system_dep): @@ -147,15 +126,20 @@ def missing_companions(entry: ModelEntry) -> List[Extra]: SGLOMNI_ENV)] -def install_companions(entry: ModelEntry, *, emit=None, cancel=None) -> int: - """pip-install ENTRY's missing companion packages into the venv. +def install_companions(entry: ModelEntry, *, emit=None, cancel=None, + force: bool = False) -> int: + """pip-install ENTRY's companion packages into the venv. The same recipe ``install_model`` runs (the catalog's ``--no-deps`` flags preserved — the Qwen3-TTS companions must not replace the pinned Transformers 5 stack), limited to what the import probe found absent, so a start-time heal touches as little of the pinned environment as - possible. Returns the first failing exit code, 0 when all present.""" - for spec, no_deps in missing_companions(entry): + possible. With FORCE every extra's pip spec re-runs instead — a + satisfied pin is a pip no-op, so the update flow uses that to heal + version drift the import probe cannot see (the protobuf re-pin + especially). Returns the first failing exit code, 0 when all present.""" + wanted = list(entry.extras) if force else missing_companions(entry) + for spec, no_deps in wanted: args = ["--no-deps"] if no_deps else None rc = common.pip_install([spec], emit=emit, cancel=cancel, env_dir=SGLOMNI_ENV, extra_args=args) @@ -237,36 +221,19 @@ def uninstall_model(key: str, *, emit=None, cancel=None) -> int: def delete_model_weights(entries: Optional[List[ModelEntry]] = None) -> int: """Delete the cached HF weight dirs of ENTRIES (every model by default). - Best-effort rmtree of each ``models--<org>--<name>`` directory; returns - how many were present and removed. Only those directories are ever - touched — the rest of the HF cache may be shared with unrelated tools. + Delegates to common.hf_delete_model_weights: best-effort rmtree of + each ``models--<org>--<name>`` directory; only those directories are + ever touched — the rest of the HF cache may be shared with unrelated + tools. Returns how many were present and removed. """ if entries is None: - entries = _all_entries() - removed = 0 - for entry in entries: - directory = model_repo_dir(entry) - if not directory.is_dir(): - continue - print(f"[INFO] Removing cached {entry.repo} weights...") - shutil.rmtree(directory, ignore_errors=True) - if directory.exists(): - print(f"[WARNING] Could not fully remove {directory}") - continue - removed += 1 - if removed: - print(f"[OK] Deleted cached weights for {removed} " - f"{'model' if removed == 1 else 'models'}.") - return removed + entries = list(ENTRIES) + return common.hf_delete_model_weights([entry.repo for entry in entries]) def _hf_download_prefix() -> Optional[List[str]]: """The sglang-omni venv's hf CLI argv prefix (None when absent).""" - for name in ("hf", "huggingface-cli"): - candidate = envs.env_script(name, SGLOMNI_ENV) - if candidate.is_file(): - return [str(candidate)] - return None + return common.hf_download_prefix(SGLOMNI_ENV) def _managed_running_repo() -> Optional[str]: @@ -292,7 +259,7 @@ def resolve_model(key: Optional[str]) -> ModelEntry: if key is not None: entry = entry_by_key(key) if entry is None: - known = ", ".join(e.key for e in _all_entries()) + known = ", ".join(e.key for e in ENTRIES) raise RuntimeError( f"Unknown sglang-omni model {key!r} (installed models are " f"picked by catalog key; known keys: {known})") diff --git a/app/backends/sglomni/pythonenv.py b/app/backends/sglomni/pythonenv.py index 11bbaa6..cb19710 100644 --- a/app/backends/sglomni/pythonenv.py +++ b/app/backends/sglomni/pythonenv.py @@ -21,10 +21,9 @@ the setup that follows anyway. import shutil import sys -from pathlib import Path from typing import Optional, Tuple -from backends import common, envs +from backends import envs from backends.sglomni.constants import PYTHON_SPEC, PYTHON_VERSIONS # The dedicated venv (its interpreter may differ from the launching one). diff --git a/app/backends/sglomni/status.py b/app/backends/sglomni/status.py index 2b77307..6a47cff 100644 --- a/app/backends/sglomni/status.py +++ b/app/backends/sglomni/status.py @@ -3,8 +3,8 @@ from pathlib import Path from typing import List, Optional -from backends import BackendStatus, ServerSpec, envs, format_launch_hint, \ - probe, servers +from backends import BackendStatus, ServerSpec, common, envs, \ + format_launch_hint, probe, servers from backends.sglomni import gpu from backends.sglomni.catalog import ModelEntry, entry_by_repo, \ fallback_config_path, config_path @@ -101,15 +101,9 @@ def gpu_fallback_note(entry: ModelEntry) -> Optional[str]: def _port() -> int: - return _port_of(config.SGLOMNI_API_URL) - - -def _port_of(url: str) -> int: - import urllib.parse - try: - return urllib.parse.urlsplit(url).port or DEFAULT_PORT - except ValueError: - return DEFAULT_PORT + """The managed server's port (the configured URL's explicit port, + else the backend default).""" + return common.port_of(config.SGLOMNI_API_URL, DEFAULT_PORT) def _managed_running_entry() -> Optional[ModelEntry]: diff --git a/app/backends/sglomni/wizard.py b/app/backends/sglomni/wizard.py index 96679d6..f39e754 100644 --- a/app/backends/sglomni/wizard.py +++ b/app/backends/sglomni/wizard.py @@ -13,7 +13,7 @@ upstream recipes + HuggingFace weight pre-download). It is driven by ``audiobook.py``'s hub but can also be run directly: Usage: - python -m backends.sglomni [--models KEY[,KEY...]] [--all] + python -m backends.sglomni [KEY ...] [--models KEY[,KEY...]] [--all] [--skip-install] [--skip-python] The Configure screen (``models_screen``) manages models after the fact — @@ -32,9 +32,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) from backends import common, envs, servers, setup from backends.sglomni import catalog as sg_catalog +from backends.sglomni import gpu as sg_gpu from backends.sglomni import models as sg_models -from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG, \ - UV_PIP_PKG +from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env from backends.sglomni.status import _is_installed from ui import taskview, tui @@ -43,10 +43,11 @@ _GO_BACK = object() def _nvidia_gpu_present() -> bool: - """True when an NVIDIA driver answers nvidia-smi (best effort).""" - proc = common.run_console_subprocess_quiet( - ["nvidia-smi", "-L"], timeout=10) - return proc is not None and proc.returncode == 0 + """True when an NVIDIA driver answers nvidia-smi (best effort). + + The same probe the launch decisions use (sglomni.gpu): nvidia-smi + names GPU 0, or there is no usable answer.""" + return sg_gpu.describe() is not None def _preflight() -> List[str]: @@ -240,6 +241,10 @@ def run_tui(args: Optional[argparse.Namespace] = None) -> int: settings = curses.wrapper(lambda scr: _wizard(scr, args)) except tui.WizardCancelled: return 1 + try: + curses.curs_set(1) # restore the text cursor hidden by the TUI + except curses.error: + pass if settings is None: return 1 return _execute(settings) @@ -258,21 +263,27 @@ def _collect_from_flags(args: argparse.Namespace, print(f"[WARNING] {warning}") if args.all: keys = [entry.key for entry in sg_catalog.ENTRIES] - elif args.models: - keys = [] - for part in args.models.split(","): + else: + # Positional keys and --models both feed the same list (the + # positional form is the --models shorthand's space-separated + # twin); duplicates collapse, unknown keys stop the run with the + # known set. + keys: List[str] = [] + for part in list(args.models_pos or []) + \ + (args.models or "").split(","): key = part.strip() if not key: continue if sg_catalog.entry_by_key(key) is None: known = ", ".join(e.key for e in sg_catalog.ENTRIES) parser.error(f"unknown model key {key!r} (known: {known})") - keys.append(key) - else: - keys = [] - print("[INFO] No --models given: installing the package only " - "(use --models KEY[,KEY...] or --all to add models, or the " - "TUI's Configure screen).") + if key not in keys: + keys.append(key) + if not keys: + print("[INFO] No models given: installing the package only " + "(pass model keys — positional or --models KEY[,KEY…] — " + "or --all to add models, or use the TUI's Configure " + "screen).") return { "keys": keys, "do_python": not args.skip_python, @@ -357,33 +368,20 @@ def models_screen(stdscr) -> int: def uninstall(*, emit=None, cancel=None) -> int: """Remove the SGLang-Omni backend entirely. - Phases: stop the managed server, pip-uninstall sglang-omni and every - catalog model's companion packages, delete every cached weight - snapshot, then remove the tool-owned venv (app/envs/sglomni — the - heavyweight CUDA stack is the install, so unlike the lighter backends - the whole environment goes) and the uv-managed interpreters under - app/envs/pythons. CANCEL is honored between phases only. Returns the - exit code (130 when cancelled before a remaining phase). + Phases: stop the managed server, delete every catalog model's cached + weight snapshot, then remove the tool-owned venv (app/envs/sglomni — + the heavyweight CUDA stack is the install, so unlike the lighter + backends the whole environment goes) and the uv-managed interpreters + under app/envs/pythons. No pip-uninstall phase: the venv removal IS + the cleanup, and pip-ing the package plus every companion out of an + environment that is about to be deleted is minutes of pure wait time. + CANCEL is honored between phases only. Returns the exit code (130 + when cancelled before a remaining phase). """ if servers.pid_for(SERVER_NAME) is not None: servers.stop(SERVER_NAME) if common.cancel_requested(cancel): return 130 - packages = [SGLOMNI_PIP_PKG] - for entry in sg_catalog.ENTRIES: - for spec, _no_deps in entry.extras: - name = spec.split("=")[0].split("<")[0].split(">")[0].strip() - if name and name not in packages: - packages.append(name) - if envs.env_exists(SGLOMNI_ENV): - rc = common.pip_uninstall(packages, emit=emit, env_dir=SGLOMNI_ENV) - if rc != 0: - print(f"[WARNING] pip uninstall failed (exit {rc}); the venv " - "is removed below anyway") - else: - rc = 0 - if common.cancel_requested(cancel): - return 130 sg_models.delete_model_weights() if common.cancel_requested(cancel): return 130 @@ -395,17 +393,23 @@ def uninstall(*, emit=None, cancel=None) -> int: print(f"[WARNING] Could not fully remove {directory}") else: print(f"[OK] {directory} removed.") - return rc + return 0 def update(*, emit=None, cancel=None) -> int: """Update the sglang-omni backend: pip install -U in its venv. A managed server that is running is stopped first (best-effort): it - imports the very package being upgraded. CANCEL is honored between - phases only. Model weights are untouched (they live in the shared - HuggingFace cache and survive package upgrades). When the venv does - not exist there is nothing to update. Returns the exit code. + imports the very package being upgraded. The upgrade is followed by a + companion refresh — every installed model's extras re-run (a pin + already satisfied is a pip no-op, so this is cheap when nothing + drifted) — so a newer sglang-omni's companion requirements are met + the way a fresh install would meet them; a failing extra warns and + leaves the update successful (the import probe re-heals it at the + next model install or server start). Model weights are untouched + (they live in the shared HuggingFace cache and survive package + upgrades). When the venv does not exist there is nothing to update. + CANCEL is honored between phases only. Returns the exit code. """ if servers.pid_for(SERVER_NAME) is not None: servers.stop(SERVER_NAME) @@ -420,9 +424,16 @@ def update(*, emit=None, cancel=None) -> int: if rc != 0: print(f"[WARNING] pip install -U failed (exit {rc}); update " f"{SGLOMNI_PIP_PKG} manually") - else: - print(f"[OK] {SGLOMNI_PIP_PKG} is up to date (or just upgraded).") - return rc + return rc + print(f"[OK] {SGLOMNI_PIP_PKG} is up to date (or just upgraded).") + for entry in sg_models.installed_entries(): + crc = sg_models.install_companions(entry, emit=emit, cancel=cancel, + force=True) + if crc != 0: + print(f"[WARNING] {entry.label}'s companion packages could " + "not all be refreshed; the next install or server start " + "retries what the import probe finds missing.") + return 0 def main() -> int: diff --git a/app/converter/clients/sglomni.py b/app/converter/clients/sglomni.py index 14c9990..6b8493b 100644 --- a/app/converter/clients/sglomni.py +++ b/app/converter/clients/sglomni.py @@ -20,7 +20,8 @@ catalog``): Clone-capable models without a reference synthesize their built-in default voice ("default") unless the catalog marks a reference as -mandatory (Qwen3-TTS Base, dots.tts, ZONOS2 — those refuse at connect). +mandatory (Qwen3-TTS Base, MOSS-TTS, dots.tts, ZONOS2 — those refuse at +connect). """ import base64 @@ -59,12 +60,6 @@ _MIME_BY_SUFFIX = { ".webm": "audio/webm", ".mp4": "audio/mp4", } -# Error-envelope types the server returns for deterministic request -# problems (bad voice, missing reference, unknown model): the identical -# request fails on every retry, so the chunk loop gives up immediately. -_NON_RETRYABLE_TYPES = ("BadRequestError", "InvalidRequestError", - "NotFoundError", "PermissionDeniedError") - # The scheduler's KV-window admission error ("Request requires more tokens # than the thinker KV cache can hold (input_tokens=684, max_new_tokens= # 12288, required_tokens=12972, kv_capacity=4095)..."): the server names @@ -130,8 +125,6 @@ class SgOmniTTSClient(BaseTTSClient): # The catalog entry this run targets (the backend package validates # the key; only its repo id and capability are client business). from backends.sglomni.catalog import entry_by_key - from backends.sglomni.constants import DEFAULT_PORT, SERVER_NAME - from backends.common import port_of self.entry = entry_by_key((model or "").strip()) if self.entry is None: raise RuntimeError( @@ -139,7 +132,6 @@ class SgOmniTTSClient(BaseTTSClient): "(see Configure Backends → SGLang-Omni or the backend docs).") self.api_url = ((api_url or config.SGLOMNI_API_URL).strip() .rstrip("/")) - self.port = port_of(self.api_url, DEFAULT_PORT) self.voice = (voice or "").strip() or None self.ref_audio = (ref_audio or "").strip() or None self.ref_text = (ref_text or "").strip() @@ -152,13 +144,18 @@ class SgOmniTTSClient(BaseTTSClient): # rejection (None = none learned): later requests keep their # max_new_tokens under it. See _kv_admission_fit. self._kv_fit = None + # The ref_audio request value, computed on first use (see + # _ref_audio_value); None = not computed yet. + self._ref_audio_cached = None # Seed sent with every request: config.SEED as-is, or (with # CONSTANT_SEED and SEED < 0) one random value drawn per run and # reused for every chunk so the voice stays consistent across # chunk boundaries. Only sent to models that accept a # request-scoped seed (Voxtral rejects it outright), and only # when a concrete seed is in play (a negative one means "re-sample - # every generation", so there is nothing to send). + # every generation", so there is nothing to send). NOTE(unverified + # upstream): whether the other pipelines accept a seed too — see + # the catalog's supports_seed note. seed = resolve_request_seed() if self.entry.supports_seed else None self._seed = seed if (seed is not None and seed >= 0) else None if language is None: @@ -188,15 +185,27 @@ class SgOmniTTSClient(BaseTTSClient): self._report(f"[WARNING] --clone is ignored with {entry.label}: " "it voices text with its built-in presets.") self.ref_audio = None + elif entry.capability == "design" and self.ref_audio: + self._report(f"[WARNING] --clone is ignored with {entry.label}: " + "it designs the voice from instructions.") + self.ref_audio = None elif self.ref_audio and not Path(self.ref_audio).is_file(): raise RuntimeError( f"Reference audio not found: {self.ref_audio}") - if entry.speakers and self.voice \ - and self.voice not in entry.speakers: + presets = self._preset_voices() + if presets and self.voice and self.voice not in presets: self._report( f"[WARNING] Voice {self.voice!r} is not one of " - f"{entry.label}'s presets ({', '.join(entry.speakers)}); " + f"{entry.label}'s presets ({', '.join(presets)}); " "the server will reject it if it does not know the name.") + + def _preset_voices(self) -> List[str]: + """The preset voice names ENTRY can speak with — the same list the + hub's voice menu offers (catalog-declared speakers, or the + checkpoint's own voice_embedding presets, e.g. Voxtral's).""" + from backends.sglomni.models import preset_voices + return preset_voices(self.entry) + def _connect(self) -> None: """Verify the server is up, healthy, and hosting the expected model. @@ -209,6 +218,24 @@ class SgOmniTTSClient(BaseTTSClient): entry, url = self.entry, self.api_url try: payload = self._fetch_json("/health", timeout=10) + except urllib.error.HTTPError as exc: + # A booting server answers 503 with an "unhealthy" body — + # urlopen turns that into an HTTPError before the healthy + # check below can see it. Tell the user to wait for the + # server that is already starting, not to start another. + detail = _http_error_detail(exc) + if exc.code == 503: + raise RuntimeError( + f"The SGLang-Omni server at {url} is not healthy yet " + f"(HTTP 503: {detail[:300] or 'no body'}). Wait for it " + "to finish booting and retry.") from exc + raise RuntimeError( + f"The SGLang-Omni server at {url} answered HTTP {exc.code} " + f"on /health ({detail[:300] or 'no body'}). Is this an " + "sgl-omni server? Start the sgl-omni server first (the " + "CLI and the hub start the managed instance automatically " + "when the backend is installed), or point --api-url at a " + "running server.") from exc except Exception as exc: raise RuntimeError( f"SGLang-Omni server not reachable at {url}: {exc}. Start " @@ -253,7 +280,10 @@ class SgOmniTTSClient(BaseTTSClient): local Whisper transcription.""" if self.entry.capability != "clone" or not self.ref_audio: return - if not self.ref_text and not self.skip_transcription: + if not self.ref_text and self.skip_transcription: + self._report("[INFO] Skipping reference audio transcription " + "(--no-transcription).") + elif not self.ref_text: self._report("[INFO] Transcribing reference audio for voice " "cloning...") from .transcribe import transcribe_reference_audio @@ -291,23 +321,40 @@ class SgOmniTTSClient(BaseTTSClient): def _ref_audio_value(self) -> str: """The ref_audio request value: a local path on a loopback server - (the server reads the file directly), else a base64 data URL.""" - path = Path(self.ref_audio) - if not path.is_file(): - raise RuntimeError( - f"Reference audio not found: {self.ref_audio}") - if _is_loopback(self.api_url): - return str(path.resolve()) - return _data_url(path) + (the server reads the file directly), else a base64 data URL. + + Computed once per run and cached: the clip is validated at connect + and cannot change mid-run, and re-encoding its bytes for every + sub-request would ship the same payload over and over.""" + cached = self._ref_audio_cached + if cached is None: + path = Path(self.ref_audio) + if not path.is_file(): + raise RuntimeError( + f"Reference audio not found: {self.ref_audio}") + if _is_loopback(self.api_url): + cached = str(path.resolve()) + else: + cached = _data_url(path) + self._ref_audio_cached = cached + return cached def _request_payload(self, text: str) -> dict: """The /v1/audio/speech JSON body for one sub-chunk.""" entry = self.entry payload = { "model": entry.repo, + # NOTE(unverified upstream): "voice" is sent even when nothing + # was picked (the "default" sentinel) and to design runs, + # which have no voice — audio.cpp omits the field there. + # Verify the server tolerates it for every pipeline. "voice": self.voice or DEFAULT_VOICE, "input": text, "response_format": RESPONSE_FORMAT, + # NOTE(unverified upstream): Qwen-style display names + # ("English", "Auto") go to every model; audio.cpp maps per + # family. Verify each pipeline accepts them (or wants ISO + # codes / the field omitted). "language": self.language, } if self._seed is not None: @@ -352,9 +399,6 @@ class SgOmniTTSClient(BaseTTSClient): exc = retry_exc detail = _http_error_detail(exc) raise self._request_error(exc.code, detail) from exc - except urllib.error.URLError as exc: - raise RuntimeError( - f"SGLang-Omni request failed: {exc.reason}") from exc def _post_speech(self, payload: dict) -> bytes: """POST PAYLOAD to /v1/audio/speech; HTTPErrors propagate raw.""" @@ -373,8 +417,12 @@ class SgOmniTTSClient(BaseTTSClient): except urllib.error.URLError as exc: raise RuntimeError( f"SGLang-Omni request failed: {exc.reason}") from exc - if not wav: - raise RuntimeError("SGLang-Omni server returned empty audio") + if len(wav) < 12 or wav[:4] != b"RIFF" or wav[8:12] != b"WAVE": + # A JSON error body handed back with HTTP 200 would otherwise + # be written as chunk bytes and fail later, confusingly, in + # the concat step. + raise RuntimeError( + "SGLang-Omni server returned audio that is not a WAV file") return wav def _kv_admission_fit(self, detail: str) -> Optional[int]: @@ -417,17 +465,17 @@ class SgOmniTTSClient(BaseTTSClient): the server's message; anything else stays retryable. """ message = detail[:500] or f"HTTP {status}" - kind = None try: envelope = json.loads(detail) error = envelope.get("error") if isinstance(error, dict): message = str(error.get("message") or message) - kind = error.get("type") except ValueError: pass - if 400 <= status < 500 and (kind is None - or kind in _NON_RETRYABLE_TYPES): + # Every 4xx envelope is deterministic — the identical request + # fails identically on every attempt (this is a single-user local + # server: it queues work rather than answering 429-style limits). + if 400 <= status < 500: return NonRetryableTTSError( f"SGLang-Omni rejected the request (HTTP {status}): " f"{message}") @@ -455,31 +503,36 @@ class SgOmniTTSClient(BaseTTSClient): if not sub_chunks: raise RuntimeError("No text to synthesize") - with self._chunk_heartbeat(chunk_num): - wav_parts: List[bytes] = [ - self._request_wav(sub_text) for sub_text in sub_chunks] - output_path = self._chunk_path(chunk_num, ".wav") - if len(wav_parts) == 1: - output_path.write_bytes(wav_parts[0]) - else: - # Several sub-request WAVs: concatenate through the shared - # ffmpeg path (each part is a complete file with headers). - with tempfile.TemporaryDirectory( - prefix="sglomni_parts_") as parts_dir: - part_paths: List[Path] = [] - for index, wav in enumerate(wav_parts, 1): + with tempfile.TemporaryDirectory( + prefix="sglomni_parts_") as parts_dir: + # One part per sub-request, spooled to disk as it arrives + # (like the other clients) instead of buffering every + # response in memory until the chunk is complete. + part_paths: List[Path] = [] + with self._chunk_heartbeat(chunk_num): + for index, sub_text in enumerate(sub_chunks, 1): part = Path(parts_dir) / f"part_{index:02d}.wav" - part.write_bytes(wav) + part.write_bytes(self._request_wav(sub_text)) part_paths.append(part) + if len(part_paths) == 1: + output_path.write_bytes(part_paths[0].read_bytes()) + else: + # Several sub-request WAVs: concatenate through the + # shared ffmpeg path (each part is a complete file + # with headers). concat_audio_files(part_paths, output_path) logger.debug("Chunk %d generated (%d sub-request(s))", - chunk_num, len(wav_parts)) + chunk_num, len(part_paths)) return str(output_path) except ConversionCancelled: raise + except NonRetryableTTSError: + # Propagate past the generic handler so the retry loop skips + # its remaining attempts for deterministic server errors. + raise except Exception as exc: logger.error("SGLang-Omni chunk processing failed for chunk " "%d: %s", chunk_num, exc) diff --git a/app/tests/test_backends_sglomni.py b/app/tests/test_backends_sglomni.py index 54659d6..d4ec12d 100644 --- a/app/tests/test_backends_sglomni.py +++ b/app/tests/test_backends_sglomni.py @@ -2,6 +2,7 @@ import io import json +import shutil import urllib.error import sys import tempfile @@ -105,7 +106,8 @@ class ModelInstallStateTests(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.cache = Path(self._tmp.name) - patcher = patch.object(models, "_hf_cache_dir", return_value=self.cache) + patcher = patch("backends.common.hf_cache_dir", + return_value=self.cache) patcher.start() self.addCleanup(patcher.stop) self.addCleanup(self._tmp.cleanup) @@ -850,8 +852,8 @@ class ModelsScreenTests(unittest.TestCase): rc, _trees, _confirms, _flashes, _runs = self._screen( [[], wizard._GO_BACK], installed=(entry.key,), extra=[ - patch.object(models, "_hf_cache_dir", - return_value=Path(td)), + patch("backends.common.hf_cache_dir", + return_value=Path(td)), patch.object(models, "_managed_running_repo", return_value=None), ]) @@ -1028,5 +1030,288 @@ class SetupWizardTests(unittest.TestCase): execute.assert_called_once_with(settings) +class SystemDepTests(unittest.TestCase): + """Docs-required system binaries are checked at install time.""" + + def test_dac_models_require_ffmpeg(self): + # The Fish Audio and ZONOS2 pipelines shell out to ffmpeg; the + # install flow must warn like it does for the Qwen entries' sox. + for key in ("fish_s2_pro", "zonos2"): + with self.subTest(key=key): + entry = entry_by_key(key) + self.assertEqual(entry.system_dep, "ffmpeg") + self.assertIn("ffmpeg", entry.system_hint) + + def test_missing_system_dep_warns(self): + with patch("shutil.which", return_value=None): + message = models.system_dep_missing(entry_by_key("fish_s2_pro")) + self.assertIsNotNone(message) + self.assertIn("ffmpeg", message) + + def test_present_system_dep_is_silent(self): + with patch("shutil.which", return_value="/usr/bin/ffmpeg"): + self.assertIsNone( + models.system_dep_missing(entry_by_key("fish_s2_pro"))) + + def test_missing_system_dep_warns_during_install(self): + out = io.StringIO() + with patch("shutil.which", return_value=None), \ + patch("backends.sglomni.models.prepare_env", return_value=0), \ + patch("backends.common.pip_install", return_value=0), \ + patch("backends.sglomni.models._hf_download_prefix", + return_value=["hf"]), \ + patch("backends.common.run_console_subprocess", + return_value=0), \ + redirect_stdout(out): + rc = models.install_model("fish_s2_pro") + self.assertEqual(rc, 0) + self.assertIn("ffmpeg", out.getvalue()) + + +class UninstallModelServerStopTests(unittest.TestCase): + """uninstall_model stops a managed server hosting the model.""" + + def test_stops_a_server_hosting_the_model(self): + entry = ENTRIES[0] + with patch.object(models, "_managed_running_repo", + return_value=entry.repo), \ + patch.object(models, "delete_model_weights"), \ + patch.object(servers, "stop") as stop: + rc = models.uninstall_model(entry.key) + self.assertEqual(rc, 0) + stop.assert_called_once_with(constants.SERVER_NAME) + + def test_leaves_a_server_hosting_something_else_alone(self): + entry = ENTRIES[0] + with patch.object(models, "_managed_running_repo", + return_value=ENTRIES[1].repo), \ + patch.object(servers, "stop") as stop, \ + patch.object(models, "delete_model_weights") as weights: + rc = models.uninstall_model(entry.key) + self.assertEqual(rc, 0) + stop.assert_not_called() + weights.assert_called_once_with([entry]) + + +class WizardUninstallTests(unittest.TestCase): + """wizard.uninstall: stop the server, delete weights, remove the venv. + + The venv removal IS the cleanup (the heavyweight CUDA stack is the + install): there is deliberately no pip-uninstall phase ahead of it. + """ + + def _run(self, *, pid=None, cancel_after=None, venv=True, pythons=True, + rmtree_removes=True): + """Run uninstall with a temp venv tree; return (rc, output, + stop_calls, weights, pip_uninstall). + + CANCEL_AFTER N lets the first N cancel checks pass (None = none + of them do). + """ + from backends.sglomni import wizard + out = io.StringIO() + with tempfile.TemporaryDirectory() as td: + venv_dir = Path(td) / "sglomni" + pythons_dir = Path(td) / "pythons" + for make, directory in ((venv, venv_dir), + (pythons, pythons_dir)): + if make: + directory.mkdir() + state = {"stops": [], "cancel": 0} + + def fake_stop(name): + state["stops"].append(name) + + def fake_cancel(_cancel): + state["cancel"] += 1 + return cancel_after is not None \ + and state["cancel"] > cancel_after + + real_rmtree = shutil.rmtree + + def fake_rmtree(path, ignore_errors=False): + if rmtree_removes: + real_rmtree(path, ignore_errors=True) + + with patch.object(servers, "pid_for", return_value=pid), \ + patch.object(servers, "stop", side_effect=fake_stop), \ + patch.object(models, "delete_model_weights") as weights, \ + patch.object(common, "pip_uninstall") as pip_uninstall, \ + patch.object(wizard, "SGLOMNI_ENV", venv_dir), \ + patch.object(envs, "PYTHON_INSTALL_DIR", pythons_dir), \ + patch.object(common, "cancel_requested", + side_effect=fake_cancel), \ + patch("shutil.rmtree", side_effect=fake_rmtree), \ + redirect_stdout(out): + rc = wizard.uninstall() + existed = venv_dir.exists() or pythons_dir.exists() + return rc, out.getvalue(), state["stops"], weights, \ + pip_uninstall, existed + + def test_removes_the_venv_tree_without_a_pip_phase(self): + rc, out, _stops, weights, pip_uninstall, existed = self._run() + self.assertEqual(rc, 0) + weights.assert_called_once_with() + pip_uninstall.assert_not_called() + self.assertFalse(existed) + + def test_stops_only_a_running_managed_server(self): + _rc, _out, stops, _w, _p, _e = self._run(pid=123) + self.assertEqual(stops, ["sglomni"]) + _rc, _out, stops, _w, _p, _e = self._run(pid=None) + self.assertEqual(stops, []) + + def test_cancel_before_anything_removes_nothing(self): + rc, _out, _stops, weights, _p, _e = self._run(cancel_after=0) + self.assertEqual(rc, 130) + weights.assert_not_called() + + def test_cancel_before_the_venv_removal_keeps_the_dirs(self): + rc, _out, _stops, weights, _p, existed = self._run(cancel_after=1) + self.assertEqual(rc, 130) + weights.assert_called_once_with() + self.assertTrue(existed) + + def test_a_stuck_venv_warns_but_the_uninstall_succeeds(self): + rc, out, _stops, _w, _p, existed = self._run(rmtree_removes=False) + self.assertEqual(rc, 0) + self.assertTrue(existed) + self.assertIn("Could not fully remove", out) + + def test_missing_venv_dirs_are_fine(self): + rc, _out, _stops, _w, _p, _e = self._run(venv=False, pythons=False) + self.assertEqual(rc, 0) + + +class WizardUpdateTests(unittest.TestCase): + """wizard.update: package upgrade, then a companion refresh. + + The refresh re-runs every installed model's extras (a satisfied pin + is a pip no-op), healing version drift the import probe cannot see. + """ + + def _run(self, *, pid=None, venv=True, pip_rc=0, companions_rc=0, + installed=(0, 1), cancel_after=None): + from backends.sglomni import wizard + out = io.StringIO() + entries = [ENTRIES[i] for i in installed] + state = {"pip": [], "companions": [], "stops": [], "cancel": 0} + + def fake_stop(name): + state["stops"].append(name) + + def fake_cancel(_cancel): + state["cancel"] += 1 + return cancel_after is not None \ + and state["cancel"] > cancel_after + + def fake_pip(specs, **kwargs): + state["pip"].append((list(specs), kwargs)) + return pip_rc + + def fake_companions(entry, **kwargs): + state["companions"].append((entry.key, kwargs.get("force"))) + return companions_rc + + with patch.object(servers, "pid_for", return_value=pid), \ + patch.object(servers, "stop", side_effect=fake_stop), \ + patch.object(envs, "env_exists", return_value=venv), \ + patch.object(common, "pip_install", side_effect=fake_pip), \ + patch.object(models, "installed_entries", + return_value=entries), \ + patch.object(models, "install_companions", + side_effect=fake_companions), \ + patch.object(common, "cancel_requested", + side_effect=fake_cancel), \ + redirect_stdout(out): + rc = wizard.update() + return rc, out.getvalue(), state + + def test_no_venv_is_a_no_op(self): + rc, _out, state = self._run(venv=False) + self.assertEqual(rc, 0) + self.assertEqual(state["pip"], []) + self.assertEqual(state["companions"], []) + + def test_stops_the_server_then_upgrades_the_package(self): + rc, _out, state = self._run(pid=123) + self.assertEqual(rc, 0) + self.assertEqual(state["stops"], ["sglomni"]) + specs, kwargs = state["pip"][0] + self.assertEqual(specs, ["sglang-omni"]) + self.assertTrue(kwargs["upgrade"]) + self.assertEqual(kwargs["extra_args"], ["--pre"]) + + def test_upgrade_failure_skips_the_companion_refresh(self): + rc, _out, state = self._run(pip_rc=23) + self.assertEqual(rc, 23) + self.assertEqual(state["companions"], []) + + def test_refreshes_every_installed_models_companions(self): + rc, _out, state = self._run(installed=(0, 4)) + self.assertEqual(rc, 0) + self.assertEqual(state["companions"], + [(ENTRIES[0].key, True), (ENTRIES[4].key, True)]) + + def test_companion_failure_warns_but_the_update_succeeds(self): + rc, out, _state = self._run(companions_rc=23) + self.assertEqual(rc, 0) + self.assertIn("companion packages", out) + + def test_cancel_before_pip_removes_nothing(self): + rc, _out, state = self._run(cancel_after=0) + self.assertEqual(rc, 130) + self.assertEqual(state["pip"], []) + + +class NonInteractiveCliTests(unittest.TestCase): + """_collect_from_flags: the flag-driven (non-TUI) setup path. + + Positional keys are the space-separated twin of --models: both feed + the same deduplicated install list. + """ + + def _collect(self, argv, *, installed=True): + from backends.sglomni import wizard + parser = wizard.build_parser() + args = parser.parse_args(argv) + with patch.object(wizard, "_preflight", return_value=[]), \ + patch.object(wizard, "_gpu_warning", return_value=None), \ + patch.object(wizard, "_is_installed", + return_value=installed): + return wizard._collect_from_flags(args, parser) + + def test_positional_keys_are_installed(self): + settings = self._collect(["higgs_audio_v3_tts"]) + self.assertEqual(settings["keys"], ["higgs_audio_v3_tts"]) + self.assertTrue(settings["do_python"]) + self.assertFalse(settings["do_install"]) + + def test_positional_and_models_flags_merge_in_order(self): + settings = self._collect(["moss_tts", "--models", + "higgs_audio_v3_tts,moss_tts"]) + self.assertEqual(settings["keys"], + ["moss_tts", "higgs_audio_v3_tts"]) + + def test_all_installs_every_catalog_model(self): + settings = self._collect(["--all"]) + self.assertEqual(settings["keys"], [entry.key for entry in ENTRIES]) + + def test_unknown_key_stops_the_run(self): + with self.assertRaises(SystemExit): + self._collect(["nope"]) + + def test_no_keys_installs_the_package_only(self): + settings = self._collect([], installed=False) + self.assertEqual(settings["keys"], []) + self.assertTrue(settings["do_install"]) + + def test_skip_flags_are_honored(self): + settings = self._collect(["--skip-python", "--skip-install", + "higgs_audio_v3_tts"]) + self.assertFalse(settings["do_python"]) + self.assertFalse(settings["do_install"]) + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_tts_sglomni.py b/app/tests/test_tts_sglomni.py index 0e3a7de..c4cdfaf 100644 --- a/app/tests/test_tts_sglomni.py +++ b/app/tests/test_tts_sglomni.py @@ -146,6 +146,29 @@ class ConnectHealthTests(unittest.TestCase): self.assertIn("not reachable", str(ctx.exception)) self.assertIn("sgl-omni", str(ctx.exception)) + def test_booting_503_tells_the_user_to_wait(self): + # A booting sgl-omni answers /health with 503 + an "unhealthy" + # body (urlopen surfaces that as an HTTPError before any JSON + # could be inspected) — the message must say wait, not start. + with patch("converter.clients.sglomni.urllib.request.urlopen", + side_effect=_http_error(503, '{"status": "unhealthy"}')): + with self.assertRaises(RuntimeError) as ctx: + SgOmniTTSClient(_DUMMY_CHUNKS, model="higgs_audio_v3_tts") + message = str(ctx.exception) + self.assertIn("not healthy yet", message) + self.assertIn("HTTP 503", message) + self.assertIn("booting", message) + self.assertNotIn("not reachable", message) + + def test_other_health_errors_name_the_code(self): + with patch("converter.clients.sglomni.urllib.request.urlopen", + side_effect=_http_error(404, "<html>nope</html>")): + with self.assertRaises(RuntimeError) as ctx: + SgOmniTTSClient(_DUMMY_CHUNKS, model="higgs_audio_v3_tts") + message = str(ctx.exception) + self.assertIn("HTTP 404", message) + self.assertIn("Is this an sgl-omni server?", message) + def test_booting_server_raises(self): with self.assertRaises(RuntimeError) as ctx: self._connect([{"status": "unhealthy"}]) @@ -192,6 +215,7 @@ class PayloadTests(unittest.TestCase): client.language = "English" client._seed = None client._kv_fit = None + client._ref_audio_cached = None return client def test_speaker_payload_sends_the_preset_name(self): @@ -237,6 +261,20 @@ class PayloadTests(unittest.TestCase): b"abc") self.assertNotIn("ref_text", payload) + def test_reference_audio_is_encoded_once_per_run(self): + # The clip cannot change mid-run: the data URL (or resolved path) + # is computed on the first sub-request and reused verbatim. + from converter.clients.sglomni import _data_url as real_data_url + client = self._make_client("higgs_audio_v3_tts", + ref_audio=str(self.ref)) + client.api_url = "http://10.20.30.40:8100" + with patch("converter.clients.sglomni._data_url", + wraps=real_data_url) as encode: + first = client._request_payload("Hello.") + second = client._request_payload("Hello again.") + self.assertEqual(encode.call_count, 1) + self.assertEqual(first["ref_audio"], second["ref_audio"]) + def test_clone_without_reference_sends_no_reference_fields(self): client = self._make_client("higgs_audio_v3_tts") payload = client._request_payload("Hello.") @@ -423,6 +461,33 @@ class KvAdmissionTests(unittest.TestCase): self.assertEqual(second["max_new_tokens"], 2531) +class ErrorClassificationTests(unittest.TestCase): + """HTTP status → retry decision: every 4xx envelope is deterministic.""" + + def _request_error(self, status, detail): + client = SgOmniTTSClient.__new__(SgOmniTTSClient) + return client._request_error(status, detail) + + def test_every_4xx_envelope_is_non_retryable(self): + # Including types outside the OpenAI-style names: the identical + # request fails identically on every attempt. + exception = self._request_error( + 401, json.dumps({"error": {"message": "bad key", + "type": "AuthenticationError"}})) + self.assertIsInstance(exception, NonRetryableTTSError) + self.assertIn("bad key", str(exception)) + + def test_non_json_4xx_bodies_are_non_retryable(self): + exception = self._request_error(400, "plain text refusal") + self.assertIsInstance(exception, NonRetryableTTSError) + self.assertIn("plain text refusal", str(exception)) + + def test_5xx_stays_retryable(self): + exception = self._request_error(500, "CUDA out of memory") + self.assertNotIsInstance(exception, NonRetryableTTSError) + self.assertIn("CUDA out of memory", str(exception)) + + class GenerateChunkTests(unittest.TestCase): """Chunk generation: WAV output, sub-chunking, bookkeeping.""" @@ -511,6 +576,40 @@ class GenerateChunkTests(unittest.TestCase): self.assertIsNone(client.generate_chunk("Hello.", 1)) self.assertEqual(mock_wav.call_count, 1) + def test_non_retryable_errors_propagate(self): + # Deterministic server errors must reach the retry loop directly + # (which skips its remaining attempts and re-raises with the + # actionable message), not come back as a generic failed attempt. + client = self._make_client() + with patch.object(client, "_request_wav", + side_effect=NonRetryableTTSError( + "unknown voice")): + with self.assertRaises(NonRetryableTTSError): + client.generate_chunk("Hello.", 1) + + def test_retry_loop_skips_remaining_attempts(self): + client = self._make_client() + with patch.object(client, "_request_wav", + side_effect=NonRetryableTTSError( + "unknown voice")) as mock_wav: + with self.assertRaises(NonRetryableTTSError): + client.process_chunk_with_retry(1, "Hello.") + self.assertEqual(mock_wav.call_count, 1) + + def test_a_non_wav_200_body_fails_the_request(self): + # A JSON error body served with HTTP 200 must not be written as + # chunk bytes (it would only fail later, confusingly, in the + # concat step). + client = self._make_client() + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = b'{"error": {"message": "nope"}}' + with patch("converter.clients.sglomni.urllib.request.urlopen", + return_value=response): + with self.assertRaises(RuntimeError) as ctx: + client._request_wav("Hello.") + self.assertIn("not a WAV file", str(ctx.exception)) + def test_stale_chunk_files_are_removed(self): stale = Path(self._tmp.name) / "chunk_0001.mp3" stale.write_bytes(b"old") |
