aboutsummaryrefslogtreecommitdiff
path: root/app/converter
diff options
context:
space:
mode:
Diffstat (limited to 'app/converter')
-rw-r--r--app/converter/clients/sglomni.py127
-rw-r--r--app/converter/converter.py75
2 files changed, 186 insertions, 16 deletions
diff --git a/app/converter/clients/sglomni.py b/app/converter/clients/sglomni.py
index d4b756f..14c9990 100644
--- a/app/converter/clients/sglomni.py
+++ b/app/converter/clients/sglomni.py
@@ -26,6 +26,7 @@ mandatory (Qwen3-TTS Base, dots.tts, ZONOS2 — those refuse at connect).
import base64
import json
import logging
+import re
import tempfile
import urllib.error
import urllib.parse
@@ -64,6 +65,21 @@ _MIME_BY_SUFFIX = {
_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
+# the numbers a refit needs, and upstream classifies the message as a
+# deterministic bad request — the identical request fails on every retry,
+# so the only useful response is to send a smaller one.
+_KV_ADMISSION_MARKER = "thinker KV cache can hold"
+
+# Frames kept below the capacity the server reported, and the smallest
+# refitted cap worth generating with (~14 s of speech at 75 fps): below
+# the floor the request would truncate almost immediately, so the run
+# surfaces guidance instead of near-empty audio.
+_KV_FIT_MARGIN = 64
+_KV_FIT_FLOOR = 1024
+
def _is_loopback(url: str) -> bool:
"""True when URL's host is this machine (the server can read local
@@ -82,6 +98,20 @@ def _data_url(path: Path) -> str:
return f"data:{mime};base64,{encoded}"
+def _http_error_detail(exc: urllib.error.HTTPError) -> str:
+ """The error response body as text (empty when it cannot be read)."""
+ try:
+ return exc.read().decode("utf-8", errors="replace")
+ except Exception:
+ return ""
+
+
+def _kv_error_number(detail: str, name: str) -> Optional[int]:
+ """The integer NAME=... reports in a KV-window admission message."""
+ match = re.search(rf"\b{name}=(\d+)", detail)
+ return int(match.group(1)) if match else None
+
+
class SgOmniTTSClient(BaseTTSClient):
"""Generates audio chunks through an SGLang-Omni server."""
@@ -94,6 +124,7 @@ class SgOmniTTSClient(BaseTTSClient):
instructions: Optional[str] = None,
language: Optional[str] = None,
api_url: Optional[str] = None,
+ chunk_size: Optional[int] = None,
quiet: bool = False, cancel=None):
super().__init__(chunks_dir, quiet=quiet, cancel=cancel)
# The catalog entry this run targets (the backend package validates
@@ -114,6 +145,13 @@ class SgOmniTTSClient(BaseTTSClient):
self.ref_text = (ref_text or "").strip()
self.skip_transcription = skip_transcription
self.instructions = (instructions or "").strip()
+ # Per-run sub-request word cap (the pre-flight chunk popup's "set
+ # chunk" answer); None follows config.CHUNK_SIZE.
+ self.chunk_size = chunk_size
+ # A KV-window capacity the server taught us via an admission
+ # rejection (None = none learned): later requests keep their
+ # max_new_tokens under it. See _kv_admission_fit.
+ self._kv_fit = 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
@@ -278,8 +316,13 @@ class SgOmniTTSClient(BaseTTSClient):
# Models whose engine caps a request below what a full
# sub-chunk can narrate (Zonos2's 1024-frame default is ~12 s):
# raise the ceiling per request. Generation still stops at
- # natural EOS, so an unused margin costs nothing.
- payload["max_new_tokens"] = entry.max_new_tokens
+ # natural EOS, so an unused margin costs nothing. A capacity
+ # learned from an admission rejection (Higgs pins the window)
+ # keeps later requests under it too.
+ cap = entry.max_new_tokens
+ if self._kv_fit is not None:
+ cap = min(cap, self._kv_fit)
+ payload["max_new_tokens"] = cap
if entry.capability == "design":
payload["task_type"] = "VoiceDesign"
payload["instructions"] = self.instructions
@@ -291,22 +334,42 @@ class SgOmniTTSClient(BaseTTSClient):
def _request_wav(self, text: str) -> bytes:
"""POST one sub-chunk and return the complete WAV bytes."""
+ payload = self._request_payload(text)
+ try:
+ return self._post_speech(payload)
+ except urllib.error.HTTPError as exc:
+ # A KV-window rejection is deterministic (upstream maps it to a
+ # bad request): refit the generation cap to the capacity the
+ # server reported and resend once before surfacing anything.
+ detail = _http_error_detail(exc)
+ fitted = (self._kv_admission_fit(detail)
+ if "max_new_tokens" in payload else None)
+ if fitted is not None:
+ try:
+ return self._post_speech(
+ dict(payload, max_new_tokens=fitted))
+ except urllib.error.HTTPError as retry_exc:
+ 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."""
url = f"{self.api_url}/v1/audio/speech"
- payload = json.dumps(self._request_payload(text)).encode("utf-8")
request = urllib.request.Request(
- url, data=payload,
+ url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request,
timeout=config.API_TIMEOUT) as response:
wav = response.read()
- except urllib.error.HTTPError as exc:
- detail = ""
- try:
- detail = exc.read().decode("utf-8", errors="replace")
- except Exception:
- pass
- raise self._request_error(exc.code, detail) from exc
+ except urllib.error.HTTPError:
+ # Re-raise raw (HTTPError subclasses URLError): the caller maps
+ # it — and refits KV-window rejections — from the status code.
+ raise
except urllib.error.URLError as exc:
raise RuntimeError(
f"SGLang-Omni request failed: {exc.reason}") from exc
@@ -314,6 +377,37 @@ class SgOmniTTSClient(BaseTTSClient):
raise RuntimeError("SGLang-Omni server returned empty audio")
return wav
+ def _kv_admission_fit(self, detail: str) -> Optional[int]:
+ """A refitted max_new_tokens for a KV-window rejection, or None.
+
+ DETAIL is the error response body. The server's message names the
+ request's prompt length and the KV window it must fit; the refit
+ keeps a small margin below the window, is remembered for this
+ client's remaining sub-requests, and the refitted request carries
+ it. When the window leaves less than a useful minimum after the
+ prompt (a very long reference clip), the run fails with guidance
+ instead of near-empty audio.
+ """
+ if _KV_ADMISSION_MARKER not in detail:
+ return None
+ input_tokens = _kv_error_number(detail, "input_tokens")
+ kv_capacity = _kv_error_number(detail, "kv_capacity")
+ if input_tokens is None or kv_capacity is None:
+ return None
+ fitted = kv_capacity - input_tokens - _KV_FIT_MARGIN
+ if fitted < _KV_FIT_FLOOR:
+ raise NonRetryableTTSError(
+ f"SGLang-Omni rejected the request: the model's KV window "
+ f"({kv_capacity} tokens) leaves {fitted} frames after this "
+ f"request's prompt ({input_tokens} tokens) — too little to "
+ "narrate anything useful. Use a shorter reference clip or "
+ "a smaller Chunk Size setting; the server caps prompt plus "
+ "generation at that window for every request.")
+ if self._kv_fit is not None:
+ fitted = min(fitted, self._kv_fit)
+ self._kv_fit = fitted
+ return fitted
+
def _request_error(self, status: int, detail: str) -> Exception:
"""Map the OpenAI-style error envelope to the retry decision.
@@ -347,14 +441,17 @@ class SgOmniTTSClient(BaseTTSClient):
def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
"""Generate one audio chunk; returns its path in the chunks folder.
- The text is split into sub-requests of at most
- ``config.CHUNK_SIZE`` words each (the book-level chunker normally
+ The text is split into sub-requests of at most ``chunk_size`` words
+ each — the per-run cap the pre-flight chunk popup sets for models
+ whose engine cannot narrate a full CHUNK_SIZE sub-chunk (Higgs),
+ else ``config.CHUNK_SIZE`` (the book-level chunker normally
guarantees this already; the split is defense in depth against
- pathological input such as a punctuation-free run of text), and
+ pathological input such as a punctuation-free run of text) — and
the returned WAV files are concatenated into one chunk file.
"""
try:
- sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ sub_chunks = split_into_chunks(
+ text, max_words=self.chunk_size or config.CHUNK_SIZE)
if not sub_chunks:
raise RuntimeError("No text to synthesize")
diff --git a/app/converter/converter.py b/app/converter/converter.py
index 0769258..3b878c8 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -220,6 +220,73 @@ def prompt_overwrite(existing: List[Path], output_name: str,
print("Please answer 'y' or 'n' (or press Enter for yes).")
+class ChunkClampCancelled(Exception):
+ """The chunk-cap popup's Cancel answer: the run stops unstarted."""
+
+
+def chunk_clamp_needed(entry) -> bool:
+ """True when ENTRY cannot narrate a full CHUNK_SIZE sub-request.
+
+ A catalog entry declares ``chunk_words`` when its engine's admission
+ window caps one request below what the configured CHUNK_SIZE can
+ narrate (Higgs: the server pins each request's prompt plus
+ generation at 4096 tokens).
+ """
+ return (entry is not None and entry.chunk_words is not None
+ and entry.chunk_words < config.CHUNK_SIZE)
+
+
+def chunk_clamp_message(entry) -> List[str]:
+ """The popup text for a chunk_words-capped ENTRY (short lines)."""
+ return [
+ f"{entry.label} can narrate at most ~{entry.chunk_words} words "
+ "per request: the server caps",
+ "each request's prompt plus generation at a fixed window. "
+ "Longer sub-chunks",
+ "may cut off mid-sentence.",
+ ]
+
+
+def prompt_chunk_clamp(entry, ask=None) -> Optional[int]:
+ """The per-run sub-request word cap for ENTRY (None = no clamp).
+
+ Models whose catalog entry declares ``chunk_words`` below CHUNK_SIZE
+ (Higgs) cannot narrate a full sub-chunk in one request; the popup
+ offers to clamp CHUNK_SIZE for this run, keep it ("try anyway",
+ risking mid-chunk truncation), or cancel the run. ASK, when given,
+ replaces the console prompt: it is called with (message lines, words)
+ and returns "clamp" or "anyway" (Cancel raises inside the callback —
+ the hub maps it to returning to the Generate form). A closed stdin
+ (non-interactive run) clamps: unattended runs keep working and never
+ produce silently truncated audio.
+ """
+ if not chunk_clamp_needed(entry):
+ return None
+ lines = chunk_clamp_message(entry)
+ if ask is not None:
+ answer = ask(lines, entry.chunk_words)
+ return entry.chunk_words if answer == "clamp" else None
+ for line in lines:
+ print(line)
+ while True:
+ try:
+ answer = input(
+ f"Set Chunk to {entry.chunk_words} for this run, try "
+ "anyway, or cancel? [S/t/c]: ").strip().lower()
+ except EOFError:
+ print(f"\n[WARNING] No interactive input available; clamping "
+ f"sub-requests to {entry.chunk_words} words for this run")
+ return entry.chunk_words
+ if answer in ("", "s", "set"):
+ return entry.chunk_words
+ if answer in ("t", "try"):
+ return None
+ if answer in ("c", "cancel"):
+ raise ChunkClampCancelled(
+ "Conversion cancelled at the chunk-size prompt")
+ print("Please answer 's', 't', or 'c'.")
+
+
class AudiobookConverter:
"""Audiobook converter using a local TTS API."""
@@ -236,6 +303,7 @@ class AudiobookConverter:
instructions: Optional[str] = None,
request_options: Optional[Dict[str, str]] = None,
api_url: Optional[str] = None,
+ chunk_size: Optional[int] = None,
unload_models: Optional[bool] = None,
progress: Optional[Callable[[dict], None]] = None,
cancel=None):
@@ -324,13 +392,18 @@ class AudiobookConverter:
"catalog key for --model (see the backend docs).")
model_id = entry.key
self.model_id = model_id
+ # CHUNK_SIZE carries a per-run override (the pre-flight chunk
+ # popup's clamp for models whose engine caps one request below
+ # a full sub-chunk); None follows the config.CHUNK_SIZE setting.
+ self.chunk_size = chunk_size
self.tts = SgOmniTTSClient(
chunks_dir=CHUNKS_FOLDER, model=model_id, voice=voice,
ref_audio=voice_clone_ref_audio,
ref_text=voice_clone_ref_text,
skip_transcription=skip_transcription,
instructions=instructions, language=self.language,
- api_url=api_url, quiet=quiet, cancel=cancel)
+ api_url=api_url, chunk_size=self.chunk_size,
+ quiet=quiet, cancel=cancel)
else:
# Qwen: the voice mode picks the request shape (built-in
# speaker, clone from a reference .wav, or a designed voice);