aboutsummaryrefslogtreecommitdiff
path: root/app/converter
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-04 17:41:43 -0400
committerhistoria <historiavg@proton.me>2026-09-04 17:41:43 -0400
commit0157ce4a347f9625e1e9d09e2bbf0fbfad722557 (patch)
treea2239553d7e8ac5cdb5931ad49482487e1f49905 /app/converter
parent5263a30356d7a7b39490e9a3cf5f6c179249500c (diff)
downloadtts-audiobook-generator-main.tar.gz
fix: sglomni retry contract, booting detection, CLI keys, companion refresh, ffmpeg depHEADmain
Diffstat (limited to 'app/converter')
-rw-r--r--app/converter/clients/sglomni.py147
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)