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 /app/converter/clients/sglomni.py | |
| parent | 5263a30356d7a7b39490e9a3cf5f6c179249500c (diff) | |
| download | tts-audiobook-generator-main.tar.gz | |
Diffstat (limited to 'app/converter/clients/sglomni.py')
| -rw-r--r-- | app/converter/clients/sglomni.py | 147 |
1 files changed, 100 insertions, 47 deletions
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) |
