diff options
| author | historia <historiavg@proton.me> | 2026-09-02 22:53:07 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-09-02 22:53:07 -0400 |
| commit | d04a2c53b926ccde0d582dbf4a7360dc0f072205 (patch) | |
| tree | d817622c7d32039d293c6b7d3141d40028533b96 | |
| parent | 7a7dca313750ee75e0f8a2a5442ca5d78e743294 (diff) | |
| download | tts-audiobook-generator-d04a2c53b926ccde0d582dbf4a7360dc0f072205.tar.gz | |
fix: warn before using a likely too-big chunk size for sglang-omni models
| -rw-r--r-- | app/backends/servers.py | 134 | ||||
| -rw-r--r-- | app/backends/sglomni/catalog.py | 32 | ||||
| -rw-r--r-- | app/backends/sglomni/configs/higgs_audio_v3_tts.yaml | 19 | ||||
| -rw-r--r-- | app/converter/clients/sglomni.py | 127 | ||||
| -rw-r--r-- | app/converter/converter.py | 75 | ||||
| -rw-r--r-- | app/docs/backend-sglomni.md | 36 | ||||
| -rw-r--r-- | app/tests/test_audiobook_cli.py | 74 | ||||
| -rw-r--r-- | app/tests/test_backends_servers.py | 194 | ||||
| -rw-r--r-- | app/tests/test_backends_sglomni.py | 18 | ||||
| -rw-r--r-- | app/tests/test_converter.py | 69 | ||||
| -rw-r--r-- | app/tests/test_hub.py | 58 | ||||
| -rw-r--r-- | app/tests/test_runview.py | 38 | ||||
| -rw-r--r-- | app/tests/test_tts_sglomni.py | 148 | ||||
| -rw-r--r-- | app/ui/hub.py | 32 | ||||
| -rw-r--r-- | app/ui/runview.py | 13 | ||||
| -rwxr-xr-x | audiobook.py | 14 |
16 files changed, 1036 insertions, 45 deletions
diff --git a/app/backends/servers.py b/app/backends/servers.py index d352717..fd28866 100644 --- a/app/backends/servers.py +++ b/app/backends/servers.py @@ -23,6 +23,7 @@ boot screen, which renders the same events. Pid/log files live under """ import os +import re import signal import subprocess import sys @@ -62,6 +63,12 @@ _BOOT_HINTS = ( "re-install the model via Configure Backends (checking the model " "installs its companion packages), or pip-install it into the " "backend's venv manually"), + # A launcher that could not take its configured port — uvicorn's bind + # failure dies outright, sglang-omni's silently moves to a random one + # (the live detection for that is _PORT_FALLBACK_RE below). + ("already in use", + "the configured port is held by another process — stop that process " + "or move this server to a free port, then start it again"), ) # Progress callback: called with an event dict. KIND is one of: @@ -70,6 +77,10 @@ _BOOT_HINTS = ( # "ready" {name, url} server is up and answering # "exited" {name, returncode, log_tail, hint} process died while booting # "timeout" {name, seconds, log_tail, hint} readiness deadline elapsed +# "port_taken" {name, taken, moved, message, log_tail} +# launcher moved the boot +# to a random port (the +# configured one was taken) # "running" {name, url} already up (no spawn) # "cancelled" {name} boot aborted via cancel # "error" {message} could not spawn the executable @@ -104,6 +115,9 @@ def _console_progress(event: dict) -> None: _print_tail(event.get("log_tail")) if event.get("hint"): print(f"[WARNING] hint: {event['hint']}") + elif kind == "port_taken": + print(f"[ERROR] {event['message']}") + _print_tail(event.get("log_tail")) elif kind == "error": print(f"[ERROR] {event['message']}") @@ -154,6 +168,62 @@ def _print_tail(tail: List[str]) -> None: print("---") +# sglang-omni's launcher, finding the requested port taken, silently binds +# a random one instead — the boot then stays healthy but unreachable at +# the configured URL (every client keeps polling the taken port until the +# start timeout). Scanned from the boot log so the boot fails in seconds +# with both port numbers named instead. +_PORT_FALLBACK_RE = re.compile( + r"Port (?P<taken>\d+) is already in use.*?" + r"Using port (?P<moved>\d+) instead", re.DOTALL) + +# How much of the previous log read is rescanned with the next chunk, so a +# fallback message split across two reads still matches (the launcher +# prints its two lines back to back; half a kilobyte is generous). +_LOG_CARRY_BYTES = 512 + + +def _read_new_log(path: Path, offset: int) -> tuple: + """Read the bytes appended to the server log since OFFSET (best effort). + + Returns ``(new_offset, text)`` — where to resume and what was read + (undecodable bytes replaced). An unreadable or unchanged file yields + the offset unchanged and ""; a shrunken file (truncated or rotated) + restarts from zero so nothing new is skipped. + """ + try: + size = path.stat().st_size + except OSError: + return offset, "" + if size < offset: + offset = 0 + if size == offset: + return offset, "" + try: + with path.open("rb") as fh: + fh.seek(offset) + raw = fh.read() + except OSError: + return offset, "" + return offset + len(raw), raw.decode("utf-8", errors="replace") + + +def _port_taken_message(spec) -> str: + """The refusal message for spawning while SPEC's URL already answers.""" + return (f"another process is listening at {spec.url} but it is not a " + f"usable {spec.name} server — stop that process (or move this " + f"server to a free port) and start again") + + +def _port_fallback_message(spec, taken: str, moved: str) -> str: + """The boot-failure message when the launcher moved ports on us.""" + return (f"the {spec.name} launcher moved the server from port " + f"{taken} (already in use by another process) to port {moved}; " + f"clients poll {taken}, so this boot cannot become ready — " + f"stop whatever holds port {taken} (or move this server to a " + f"free port) and start again") + + def _pid_alive(pid: int) -> bool: """True when a process with PID is still running (POSIX signal-0 probe).""" if sys.platform == "win32": @@ -286,15 +356,19 @@ def _kill_pid(pid: int) -> bool: return True -def _server_ready(spec) -> bool: +def _server_ready(spec, listening: Optional[bool] = None) -> bool: """True when the server described by SPEC is usable, not just listening. Without an IDENTITY this is the plain TCP-connect check. With one, the server must also answer HTTP as that backend (``probe.identify_server``); for the faster backend (whose model loads after the port opens) the - ``/health`` model_loaded flag must additionally be true. + ``/health`` model_loaded flag must additionally be true. LISTENING, when + given, is a caller's fresh ``common.server_running`` result — reused so + one start pass probes the port only once. """ - if not common.server_running(spec.url): + if listening is None: + listening = common.server_running(spec.url) + if not listening: return False identity = getattr(spec, "identity", None) if identity is None: @@ -320,6 +394,13 @@ def start(spec, progress: ProgressCallback = None, exit reports the log tail and returns False. A no-op (True) when the server is already running. + Two port-conflict guards fail fast instead of letting a doomed boot + run out the clock: a foreign process already listening on the spec's + URL (but not answering as the backend) refuses the spawn outright, and + a launcher that logs a port fallback mid-boot ("Port N is already in + use ... Using port M instead" — sglang-omni's) aborts the boot with a + "port_taken" event and kills the misdirected server. + PROGRESS, when given, receives each boot event (see ProgressCallback); the default ``_console_progress`` prints them, preserving the old console output. CANCEL (a threading.Event) aborts the boot: the spawned process @@ -333,7 +414,11 @@ def start(spec, progress: ProgressCallback = None, "message": f"server executable not found: {exe}. Run 'Set " "up a backend' first."}) return False - if _server_ready(spec): + # One TCP probe feeds both checks: an already-usable server takes the + # "running" path, and a listener that is NOT usable (identity probe + # failed) is exactly the foreign-holder conflict refused below. + listening = common.server_running(spec.url) + if _server_ready(spec, listening): report({"kind": "running", "name": spec.name, "url": spec.url}) return True @@ -350,6 +435,15 @@ def start(spec, progress: ProgressCallback = None, f"{pid_for(spec.name)}) is already starting or " "running; stop it first"}) return False + # Refuse to spawn onto a port a foreign process already holds: TCP-up + # but probe-down means the listener is not a usable instance of this + # server. A fresh spawn would then either die on the bind or (launchers + # that fall back silently, like sglang-omni) move to a random port and + # leave every client polling the taken one — the boot watchdog below + # catches that late, so name the conflict here. + if listening: + report({"kind": "error", "message": _port_taken_message(spec)}) + return False pid_file = _pid_path(spec.name) if pid_file.exists(): try: @@ -419,6 +513,13 @@ def start(spec, progress: ProgressCallback = None, started = time.time() next_heartbeat = started + 15 deadline = started + start_timeout + # Boot-log watchdog state: scan only what this boot appends (the log + # file accumulates across boots, so a previous boot's fallback lines + # must not re-fire here), carrying a tail between reads so a fallback + # message split across two polls still matches. + log_path = _log_path(spec.name) + log_offset = log_path.stat().st_size if log_path.exists() else 0 + log_carry = "" while time.time() < deadline: if cancel is not None and cancel.is_set(): # User cancelled while booting: kill what we spawned (the @@ -441,6 +542,31 @@ def start(spec, progress: ProgressCallback = None, except OSError: pass return False + # Watchdog: a launcher that silently moved to another port (the + # configured one was taken) keeps booting healthily where no + # client will ever call it — abort now instead of polling the + # taken port until the timeout. + log_offset, new_text = _read_new_log(log_path, log_offset) + if new_text: + scan = log_carry + new_text + match = _PORT_FALLBACK_RE.search(scan) + if match is not None: + # Kill the misdirected server: it would serve on a port + # no client will call while holding GPU memory. + _kill_pid(proc.pid) + try: + pid_file.unlink() + except OSError: + pass + report({"kind": "port_taken", "name": spec.name, + "taken": int(match.group("taken")), + "moved": int(match.group("moved")), + "message": _port_fallback_message( + spec, match.group("taken"), + match.group("moved")), + "log_tail": _read_log_tail(spec.name)}) + return False + log_carry = scan[-_LOG_CARRY_BYTES:] if _server_ready(spec): report({"kind": "ready", "name": spec.name, "url": spec.url}) return True diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py index 1e4ca2d..160eb8b 100644 --- a/app/backends/sglomni/catalog.py +++ b/app/backends/sglomni/catalog.py @@ -69,11 +69,20 @@ class ModelEntry: # 86.13 fps, ~12 s) and Higgs's to 2048 frames (75 fps, ~27 s, and # per-request values are clamped to the engine cap, so its vendored # config raises the cap too) — and silently truncate longer text. - # 12288 frames covers a full 250-word CHUNK_SIZE sub-chunk on both and - # stays under the KV-pool admission check on every GPU that can host - # the model (the scheduler rejects prompt + max_new_tokens above it, - # and these requests are not auto-clamped). + # 12288 frames covers a full 250-word CHUNK_SIZE sub-chunk on ZONOS2 + # (whose KV pools are >= 28083 tokens on every GPU that can host it) + # but NOT on Higgs: its thinker engine pins context_length at 4096, + # and the scheduler rejects any request whose prompt tokens plus + # max_new_tokens exceed that window (kv_capacity=4095, on every GPU — + # the pool side is never the binding constraint there). max_new_tokens: Optional[int] = None + # The largest sub-request (words) the model can narrate within its + # admission window, for models whose engine cannot cover a full + # CHUNK_SIZE sub-chunk (None = no cap; config.CHUNK_SIZE stands). + # Higgs: 3000 frames ≈ 40 s at 75 fps ≈ 80 words of narration, and + # the run pre-flight offers to clamp CHUNK_SIZE for the run (the + # client's adaptive retry still rescues requests the window rejects). + chunk_words: Optional[int] = None # The Qwen3-TTS CustomVoice speaker table — the same built-in speakers the @@ -177,9 +186,18 @@ ENTRIES: Tuple[ModelEntry, ...] = ( requires_reference=False, # The engine's 2048-frame default is ~27 s of speech at the codec's # 75 fps; requests are clamped to the engine cap server-side, so the - # yaml raises the cap and every request carries 12288 frames - # (~164 s) — enough for a full CHUNK_SIZE sub-chunk. - max_new_tokens=12288, + # yaml raises the cap and every request carries 3000 frames (~40 s) + # — the most the admission window allows: upstream pins the thinker + # engine's context_length at 4096, and the scheduler rejects any + # request whose prompt (including the reference-audio tokens) plus + # max_new_tokens exceeds it. 3000 frames leaves ~1095 tokens of + # prompt headroom (an 80-word chunk with a 20.5 s reference + # measured 684). Sub-requests cap at 80 words so the text fits the + # window too — the pre-flight offers to clamp CHUNK_SIZE for the + # run, and the client refits rejected requests to whatever the + # server reports as its capacity. + max_new_tokens=3000, + chunk_words=80, notes="zero-shot narration, cloning from a reference clip", ), ModelEntry( diff --git a/app/backends/sglomni/configs/higgs_audio_v3_tts.yaml b/app/backends/sglomni/configs/higgs_audio_v3_tts.yaml index 74736fa..8a42771 100644 --- a/app/backends/sglomni/configs/higgs_audio_v3_tts.yaml +++ b/app/backends/sglomni/configs/higgs_audio_v3_tts.yaml @@ -9,22 +9,27 @@ # "CUDA out of memory. Tried to allocate 14.00 MiB". # # 0.80 trims the engine's static pool by ~1.2 GB per 24 GB of VRAM while -# leaving a KV cache pool (~10 GB on a 24 GB card) far larger than any -# narration request needs. Cards with heavy other-GPU-process usage can go -# lower (e.g. 0.75). +# leaving a KV cache pool far larger than any narration request needs. +# Cards with heavy other-GPU-process usage can go lower (e.g. 0.75). # # The tts_engine factory also caps every request at max_new_tokens=2048 # audio frames, and per-request values are clamped to that cap server-side # (make_higgs_scheduler_adapters) — the Higgs codec runs 75 frames per # second (24 kHz / 320 downsample), so the default is ~27 s of speech, which # silently truncates this tool's full 250-word sub-chunks (~100 s). Raising -# the factory cap is the only way past it; the catalog also sends -# max_new_tokens=12288 per request (the same value ZONOS2 uses) so a request -# may use the room: 12288 frames ≈ 164 s. +# the factory cap is the only way past it, but the ceiling is hard: upstream +# pins the thinker engine's context_length at 4096 (HiggsTtsEngineBuilder — +# not overridable), and the scheduler rejects any request whose prompt +# tokens (including the reference-audio tokens) plus max_new_tokens exceed +# that window ("Request requires more tokens than the thinker KV cache can +# hold", kv_capacity=4095, on every GPU). The cap therefore lands at 3000 +# frames ≈ 40 s — the most the window allows with prompt headroom (an +# 80-word chunk with a 20.5 s reference measured 684 prompt tokens) — and +# the catalog caps sub-requests at 80 words to match (chunk_words). config_cls: HiggsTtsPipelineConfig model_path: bosonai/higgs-audio-v3-tts-4b stages: tts_engine: gpu_memory_fraction: 0.80 factory: - max_new_tokens: 12288 + max_new_tokens: 3000 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); diff --git a/app/docs/backend-sglomni.md b/app/docs/backend-sglomni.md index 4f553f9..58e5ce5 100644 --- a/app/docs/backend-sglomni.md +++ b/app/docs/backend-sglomni.md @@ -60,7 +60,7 @@ inside the start timeout. | `qwen3_tts_0_6b_base` | Qwen3-TTS 0.6B Base | clone (reference required) | | | `qwen3_tts_1_7b_base` | Qwen3-TTS 1.7B Base | clone (reference required) | higher quality | | `qwen3_tts_1_7b_voicedesign` | Qwen3-TTS 1.7B VoiceDesign | `--instructions` | | -| `higgs_audio_v3_tts` | Higgs Audio v3 TTS | default voice or clone | launches with a vendored config: VRAM headroom for 24 GB cards + raised generation cap (requests carry `max_new_tokens=12288`; the engine's 2048-frame default is ~27 s and clamps per-request values) | +| `higgs_audio_v3_tts` | Higgs Audio v3 TTS | default voice or clone | launches with a vendored config: VRAM headroom for 24 GB cards + a raised generation cap (requests carry `max_new_tokens=3000` — upstream pins the thinker engine's context length at 4096, so prompt + generation must fit 4095 tokens, ~40 s of speech — and sub-requests cap at 80 words, with a pre-run popup offering the CHUNK_SIZE clamp) | | `moss_tts` | MOSS-TTS v1.5 | clone (reference required) | | | `moss_tts_local` | MOSS-TTS Local v1.5 | default voice or clone | 48 kHz | | `voxtral_tts` | Voxtral TTS 4B | preset named voices | e.g. `default`, `casual_male` | @@ -108,6 +108,28 @@ Unlike `audio.cpp` (server-side voice presets) the reference clip travels Reference clips live in the project's `voices/` directory (10-20 seconds of clean speech recommended). +### Higgs Audio v3's request window + +The Higgs server pins its thinker engine's context length at 4096 tokens +(not configurable), and the scheduler rejects any request whose prompt — +including the reference-audio tokens — plus `max_new_tokens` exceeds that +window ("Request requires more tokens than the thinker KV cache can hold", +on every GPU: the KV pool side is never the binding constraint). One +request can therefore narrate at most ~40 s of speech, so: + +- Requests carry `max_new_tokens=3000` (the vendored config raises the + engine's 2048-frame default to match), and sub-requests cap at 80 words. + Before a run with Higgs, a popup offers to clamp CHUNK_SIZE for the run + ("Set Chunk to 80"), keep the configured size (audio may cut off + mid-chunk), or cancel; CLI runs answer on the console (`[S/t/c]`, and a + closed stdin clamps so unattended runs never truncate silently). +- When a request is still rejected (a long reference clip, for example), + the client refits `max_new_tokens` to the capacity the server reports + and resends once — the same request never retries as-is. +- Voice cloning with a *long* reference clip eats into the same window: + keep references around 10-20 seconds (as recommended above) for the + most usable generation headroom. + ## Server lifecycle The hub and the CLI start and stop the managed instance around each run @@ -136,6 +158,18 @@ CLI's `--api-url`) at an `sgl-omni` instance. The hub discovers it via model from `GET /v1/models`; models uploaded to that server via `POST /v1/audio/voices` appear in its Voice menu. +## Troubleshooting + +**The generate screen sits on "Status: starting", and the server log says +`Port 8100 is already in use ... Using port 37183 instead`.** Something +else already holds the configured port (often a stale `sgl-omni` from an +earlier attempt). The upstream launcher does not fail — it silently moves +the server to a random port, where no client ever looks for it. The app +detects this in the boot log, aborts the boot, and names both ports. +Find and stop the process holding the port +(`ss -tlnp 'sport = :8100'`), or move this server to a free port +(Settings → SGLang-Omni port), then start again. + ## Manual setup ```bash diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py index ab13107..f25ec23 100644 --- a/app/tests/test_audiobook_cli.py +++ b/app/tests/test_audiobook_cli.py @@ -334,6 +334,80 @@ class ConvertWiringTests(unittest.TestCase): self._convert(output_file=self.tmp / "dune.mp3") +class SglomniChunkClampTests(unittest.TestCase): + """convert(backend="sglomni"): the chunk-cap popup runs pre-flight + for CLI runs and its answer rides chunk_size into the converter.""" + + def setUp(self): + self.tmp = Path(tempfile.mkdtemp(prefix="audiobook_sglomni_")) + self.addCleanup(shutil.rmtree, self.tmp, True) + self.book = _make_book(self.tmp) + self._old_folders = (converter_mod.BOOKS_FOLDER, + converter_mod.AUDIOBOOKS_FOLDER) + self.addCleanup(self._restore_folders) + + def _restore_folders(self): + converter_mod.BOOKS_FOLDER, converter_mod.AUDIOBOOKS_FOLDER = \ + self._old_folders + + def _convert(self, *, prompt=None, **kwargs): + """Run convert() for a higgs run with the prompt mocked. + + PROMPT replaces converter.prompt_chunk_clamp (default: a MagicMock + answering 80). Returns (code, prompt mock, converter ctor kwargs). + """ + if prompt is None: + prompt = MagicMock(return_value=80) + preflight = MagicMock( + return_value=([self.book], [(self.book, "dune")])) + fake_instance = MagicMock() + fake_instance.run.return_value = True + fake_class = MagicMock(return_value=fake_instance) + fake_class.preflight_overwrites = preflight + stdout = io.StringIO() + with patch.object(audiobook, "setup_logging"), \ + patch.object(audiobook, "setup_directories"), \ + patch.object(audiobook, "AudiobookConverter", fake_class), \ + patch.object(converter_mod, "prompt_chunk_clamp", prompt), \ + contextlib.redirect_stdout(stdout): + code = audiobook.convert( + backend="sglomni", model_id="higgs_audio_v3_tts", + api_url="http://127.0.0.1:8100", **kwargs) + # A cancelled run stops before any converter is constructed. + ctor = (fake_class.call_args.kwargs + if fake_class.call_args is not None else None) + return code, prompt, stdout, ctor + + def test_cli_run_asks_and_carries_the_clamp(self): + code, prompt, _, ctor = self._convert() + self.assertEqual(code, 0) + self.assertEqual(prompt.call_args[0][0].key, "higgs_audio_v3_tts") + self.assertEqual(ctor["chunk_size"], 80) + + def test_prompt_anyway_sends_no_clamp(self): + _, prompt, _, ctor = self._convert( + prompt=MagicMock(return_value=None)) + self.assertIsNone(ctor["chunk_size"]) + + def test_prompt_cancel_stops_the_run_unstarted(self): + def cancel(entry): + raise converter_mod.ChunkClampCancelled("cancelled") + code, prompt, stdout, ctor = self._convert(prompt=cancel) + self.assertEqual(code, 0) + self.assertIn("Conversion cancelled", stdout.getvalue()) + self.assertFalse(ctor) + + def test_hub_run_with_a_plan_skips_the_prompt(self): + # The hub pre-flights inside the TUI (where the popup lives) and + # carries the answer as chunk_size; convert() must not re-ask. + _, prompt, _, ctor = self._convert( + prompt=MagicMock(side_effect=AssertionError("should not ask")), + book_files=[self.book], + planned=[(self.book, "dune")], + chunk_size=80) + self.assertEqual(ctor["chunk_size"], 80) + + class AllModelsConvertTests(unittest.TestCase): """convert(model_ids=...): the "All (multiple generation)" loop. diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py index 569c7bd..9fd71f2 100644 --- a/app/tests/test_backends_servers.py +++ b/app/tests/test_backends_servers.py @@ -168,6 +168,13 @@ class StartTests(unittest.TestCase): ['File "...", in resolve_checkpoint', "ModuleNotFoundError: No module named 'qwen_tts'"])) + def test_boot_hint_names_a_taken_port(self): + # uvicorn's bind failure (a launcher without a port fallback) is + # the exited-path face of the port-conflict problem. + self.assertIn("held by another process", servers._boot_hint( + ["OSError: [Errno 98] error while attempting to bind on " + "address 0.0.0.0:9999: address already in use"])) + def test_console_progress_prints_the_hint(self): out = io.StringIO() with redirect_stdout(out): @@ -222,16 +229,20 @@ class StartTests(unittest.TestCase): """Readiness needs the server to answer HTTP as its identity. A TCP-accepting but still-booting server (lazy model load, slow - listen-before-serve) must not count as ready. + listen-before-serve) must not count as ready. The port opens only + once the spawned server binds it: free at the spawn-time probe, + answering TCP from the first poll on, with the HTTP identity + trailing one iteration behind. """ spec = ServerSpec("test", "http://127.0.0.1:9999", [str(self.exe)], identity="audiocpp") proc = self._boot_proc() with patch.object(servers, "LOG_DIR", self.dir), \ patch("subprocess.Popen", return_value=proc), \ - patch("backends.common.server_running", return_value=True), \ + patch("backends.common.server_running", + side_effect=[False, True, True]), \ patch.object(servers.probe, "identify_server", - side_effect=[None, None, "audiocpp"]), \ + side_effect=[None, "audiocpp"]), \ patch("time.sleep"): ok = servers.start(spec) self.assertTrue(ok) @@ -243,7 +254,8 @@ class StartTests(unittest.TestCase): proc = self._boot_proc() with patch.object(servers, "LOG_DIR", self.dir), \ patch("subprocess.Popen", return_value=proc), \ - patch("backends.common.server_running", return_value=True), \ + patch("backends.common.server_running", + side_effect=[False, True, True]), \ patch.object(servers.probe, "identify_server", return_value="faster"), \ patch.object(servers.probe, "faster_model_loaded", @@ -467,5 +479,179 @@ class PidForTests(unittest.TestCase): self.assertEqual(servers.pid_for("test"), 555) +class ReadNewLogTests(unittest.TestCase): + """``_read_new_log``: incremental boot-log scanning by byte offset.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_reads_only_bytes_appended_since_offset(self): + log = self.dir / "log" + log.write_text("one\n", encoding="utf-8") + offset, text = servers._read_new_log(log, 0) + self.assertEqual(text, "one\n") + self.assertEqual(offset, 4) + self.assertEqual(servers._read_new_log(log, offset), (4, "")) + with log.open("a", encoding="utf-8") as fh: + fh.write("two\n") + offset, text = servers._read_new_log(log, offset) + self.assertEqual(text, "two\n") + self.assertEqual(offset, 8) + + def test_truncated_log_restarts_from_zero(self): + log = self.dir / "log" + log.write_text("x" * 100, encoding="utf-8") + offset, _text = servers._read_new_log(log, 0) + log.write_text("new", encoding="utf-8") + offset, text = servers._read_new_log(log, offset) + self.assertEqual(text, "new") + self.assertEqual(offset, 3) + + def test_missing_file_yields_empty(self): + self.assertEqual(servers._read_new_log(self.dir / "nope", 0), + (0, "")) + + def test_undecodable_bytes_are_replaced_not_raised(self): + log = self.dir / "log" + log.write_bytes(b"ok \xff done\n") + _offset, text = servers._read_new_log(log, 0) + self.assertIn("done", text) + + +class PortConflictTests(unittest.TestCase): + """Doomed boots fail fast instead of polling the wrong port. + + A foreign process on the configured port refuses the spawn outright, + and a launcher that logs a silent port fallback (sglang-omni's "Using + port N instead") aborts the boot the moment the line appears — the + server would keep booting healthily where no client ever polls. + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.dir = Path(self._tmp.name) + self.exe = self.dir / "fake_server" + self.exe.write_bytes(b"#!/bin/sh\n") + self.spec = ServerSpec("test", "http://127.0.0.1:9999", + [str(self.exe), "--port", "9999"], + identity="sglomni") + + def tearDown(self): + self._tmp.cleanup() + + def test_refuses_to_spawn_when_port_held_by_foreign_process(self): + # TCP-up but identity-down at the spec's URL: the listener is not + # a usable instance of this server, so a fresh spawn would either + # die on the bind or move to a random port. Refuse and name it. + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen") as mk, \ + patch("backends.common.server_running", return_value=True), \ + patch("backends.probe.identify_server", return_value=None): + events = [] + ok = servers.start(self.spec, progress=events.append) + self.assertFalse(ok) + mk.assert_not_called() + self.assertEqual([e["kind"] for e in events], ["error"]) + self.assertIn("listening at http://127.0.0.1:9999", + events[0]["message"]) + self.assertIn("stop that process", events[0]["message"]) + + def test_healthy_server_on_the_port_is_reused_not_refused(self): + # The pre-flight must not turn "already running" into a conflict: + # an endpoint answering as the backend takes the running path. + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen") as mk, \ + patch("backends.common.server_running", return_value=True), \ + patch("backends.probe.identify_server", + return_value="sglomni"): + events = [] + ok = servers.start(self.spec, progress=events.append) + self.assertTrue(ok) + mk.assert_not_called() + self.assertEqual([e["kind"] for e in events], ["running"]) + + def _boot_proc(self): + proc = MagicMock() + proc.pid = 4242 + proc.poll.return_value = None + return proc + + def test_boot_aborts_when_the_launcher_moves_to_another_port(self): + proc = self._boot_proc() + fallback = ("[WARNING] Port 9999 is already in use on 0.0.0.0.\n" + "[WARNING] Using port 37183 instead.\n") + size = len(fallback.encode("utf-8")) + reads = iter([(0, ""), (size, fallback)]) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc) as mk, \ + patch("backends.common.server_running", return_value=False), \ + patch.object(servers, "_read_new_log", + side_effect=lambda path, off: next(reads)), \ + patch.object(servers, "_kill_pid") as mk_kill, \ + patch("time.sleep"): + events = [] + ok = servers.start(self.spec, progress=events.append) + self.assertFalse(ok) + mk.assert_called_once() + # The misdirected server is killed and unrecorded: it would serve + # on a port no client ever polls while holding GPU memory. + mk_kill.assert_called_once_with(4242) + self.assertFalse((self.dir / "test-server.pid").exists()) + self.assertEqual([e["kind"] for e in events], + ["starting", "port_taken"]) + event = events[-1] + self.assertEqual(event["taken"], 9999) + self.assertEqual(event["moved"], 37183) + self.assertIn("moved the server from port 9999", event["message"]) + self.assertIn("stop whatever holds port 9999", event["message"]) + + def test_fallback_split_across_log_reads_still_matches(self): + # The launcher prints its two lines back to back, but a 1 s poll + # boundary can fall between them — the carried tail re-scans them + # together. + proc = self._boot_proc() + part_a = "WARNING: Port 9999 is already in use on 0.0.0.0.\n" + part_b = "WARNING: Using port 37183 instead.\n" + off_a = len(part_a.encode("utf-8")) + off_b = off_a + len(part_b.encode("utf-8")) + reads = iter([(0, ""), (off_a, part_a), (off_b, part_b)]) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc), \ + patch("backends.common.server_running", return_value=False), \ + patch.object(servers, "_read_new_log", + side_effect=lambda path, off: next(reads)), \ + patch.object(servers, "_kill_pid") as mk_kill, \ + patch("time.sleep"): + events = [] + ok = servers.start(self.spec, progress=events.append) + self.assertFalse(ok) + mk_kill.assert_called_once_with(4242) + self.assertEqual(events[-1]["kind"], "port_taken") + + def test_console_progress_prints_the_port_taken_message(self): + proc = self._boot_proc() + fallback = ("Port 9999 is already in use on 0.0.0.0.\n" + "Using port 37183 instead.\n") + size = len(fallback.encode("utf-8")) + reads = iter([(0, ""), (size, fallback)]) + with patch.object(servers, "LOG_DIR", self.dir), \ + patch("subprocess.Popen", return_value=proc), \ + patch("backends.common.server_running", return_value=False), \ + patch.object(servers, "_read_new_log", + side_effect=lambda path, off: next(reads)), \ + patch.object(servers, "_kill_pid"), \ + patch("time.sleep"): + out = io.StringIO() + with redirect_stdout(out): + servers.start(self.spec) + self.assertIn("moved the server from port 9999", out.getvalue()) + self.assertIn("(already in use by another process) to port 37183", + out.getvalue()) + + if __name__ == "__main__": unittest.main() diff --git a/app/tests/test_backends_sglomni.py b/app/tests/test_backends_sglomni.py index ebac500..8cb86cb 100644 --- a/app/tests/test_backends_sglomni.py +++ b/app/tests/test_backends_sglomni.py @@ -395,12 +395,24 @@ class HiggsConfigTests(unittest.TestCase): self.assertRegex(text, r"gpu_memory_fraction:\s*0\.80") # The engine's 2048-frame default (~27 s at 75 fps) silently # truncates a full 250-word sub-chunk; per-request values are - # clamped to this factory cap server-side. - self.assertRegex(text, r"max_new_tokens:\s*12288") + # clamped to this factory cap server-side. 3000 frames (~40 s) + # is the most the pinned 4095-token admission window allows + # after the prompt tokens. + self.assertRegex(text, r"max_new_tokens:\s*3000") def test_entry_sends_the_raised_frame_cap_per_request(self): entry = entry_by_key("higgs_audio_v3_tts") - self.assertEqual(entry.max_new_tokens, 12288) + self.assertEqual(entry.max_new_tokens, 3000) + + def test_entry_caps_sub_requests_for_the_admission_window(self): + """The server pins prompt + generation at 4096 tokens for Higgs; + 80 words (~30-40 s at 75 fps) narrates inside the 3000-frame + cap, and the pre-flight popup offers the clamp for a run.""" + entry = entry_by_key("higgs_audio_v3_tts") + self.assertEqual(entry.chunk_words, 80) + # A full CHUNK_SIZE sub-chunk does NOT fit one Higgs request. + self.assertLess(entry.chunk_words, 250) + self.assertIsNone(entry_by_key("zonos2").chunk_words) class Fp8FallbackTests(unittest.TestCase): diff --git a/app/tests/test_converter.py b/app/tests/test_converter.py index 0b0eb0d..85ae6ca 100644 --- a/app/tests/test_converter.py +++ b/app/tests/test_converter.py @@ -21,7 +21,11 @@ from converter.clients import ( from converter import converter as converter_mod from converter.converter import ( AudiobookConverter, + ChunkClampCancelled, + chunk_clamp_message, + chunk_clamp_needed, find_existing_outputs, + prompt_chunk_clamp, prompt_overwrite, setup_logging, ) @@ -554,6 +558,71 @@ class PromptOverwriteTests(unittest.TestCase): self.assertIn("overwrite them", prompt_text) +class ChunkClampPromptTests(unittest.TestCase): + """The chunk-cap popup for models that cannot narrate a full + CHUNK_SIZE sub-request (Higgs).""" + + def _entry(self): + from backends.sglomni.catalog import entry_by_key + return entry_by_key("higgs_audio_v3_tts") + + def test_uncapped_models_need_no_clamp(self): + from backends.sglomni.catalog import entry_by_key + self.assertFalse(chunk_clamp_needed(None)) + self.assertFalse(chunk_clamp_needed(entry_by_key("zonos2"))) + + def test_higgs_needs_a_clamp_at_the_default_chunk_size(self): + self.assertTrue(chunk_clamp_needed(self._entry())) + + def test_message_names_the_model_and_the_cap(self): + lines = chunk_clamp_message(self._entry()) + text = " ".join(lines) + self.assertIn("Higgs Audio v3 TTS", text) + self.assertIn("80 words", text) + self.assertIn("cut off mid-sentence", text) + + def test_clamped_models_at_or_below_the_cap_need_no_clamp(self): + with patch.object(config, "CHUNK_SIZE", 80): + self.assertFalse(chunk_clamp_needed(self._entry())) + + def test_prompt_answers(self): + entry = self._entry() + with patch("builtins.input", return_value=""): + self.assertEqual(prompt_chunk_clamp(entry), 80) + with patch("builtins.input", return_value="s"): + self.assertEqual(prompt_chunk_clamp(entry), 80) + with patch("builtins.input", return_value="t"): + self.assertIsNone(prompt_chunk_clamp(entry)) + with patch("builtins.input", return_value="cancel"): + with self.assertRaises(ChunkClampCancelled): + prompt_chunk_clamp(entry) + + def test_prompt_invalid_answer_reasked(self): + with patch("builtins.input", side_effect=["maybe", "t"]) as mock_input: + self.assertIsNone(prompt_chunk_clamp(self._entry())) + self.assertEqual(mock_input.call_count, 2) + + def test_prompt_eof_clamps_for_unattended_runs(self): + with patch("builtins.input", side_effect=EOFError): + self.assertEqual(prompt_chunk_clamp(self._entry()), 80) + + def test_ask_callback_replaces_the_console(self): + calls = [] + + def ask(lines, words): + calls.append((lines, words)) + return "clamp" + + self.assertEqual(prompt_chunk_clamp(self._entry(), ask=ask), 80) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][1], 80) + + def test_ask_anyway_returns_no_clamp(self): + self.assertIsNone(prompt_chunk_clamp( + self._entry(), ask=lambda lines, words: "anyway")) + + + class PreflightOverwritesTests(unittest.TestCase): """The pre-flight overwrite check runs without a TTS server connection.""" diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py index e38acbe..84796ee 100644 --- a/app/tests/test_hub.py +++ b/app/tests/test_hub.py @@ -2661,6 +2661,8 @@ class ConvertFlowTests(unittest.TestCase): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, voice=str(ref), instructions="") + # Higgs's chunk-cap popup: keep the configured chunk size. + self.tui.script.append("anyway") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) fields = self.tui.forms_seen[-1][1] @@ -2691,6 +2693,8 @@ class ConvertFlowTests(unittest.TestCase): self._mock_preflight() self._answer_form(backend="sglomni", model_id=entry.key, voice="", instructions="") + # Higgs's chunk-cap popup: keep the configured chunk size. + self.tui.script.append("anyway") cmd = self._convert(None, [self._ready("sglomni", "SGLang-Omni")]) kwargs = cmd[2] @@ -2738,6 +2742,8 @@ class ConvertFlowTests(unittest.TestCase): self._answer_form(backend="sglomni-remote", model_id="higgs_audio_v3_tts", voice="narrator", instructions="") + # Higgs's chunk-cap popup: keep the configured chunk size. + self.tui.script.append("anyway") cmd = self._convert( None, [self._remote("sglomni", "SGLang-Omni", url="http://sgl.local:8100")]) @@ -2870,6 +2876,10 @@ class ConvertFlowTests(unittest.TestCase): else: # design overrides["instructions"] = "A warm narrator." self._answer_form(**overrides) + if hub.converter_mod.chunk_clamp_needed(entry): + # Higgs's chunk-cap popup: keep the configured + # chunk size. + self.tui.script.append("anyway") cmd = self._convert(None, [ self._ready("sglomni", "SGLang-Omni")]) self.assertIsNotNone(cmd) @@ -3196,6 +3206,54 @@ class PreflightTests(unittest.TestCase): with self.assertRaises(hub._BackToForm): confirm("overwrite?", True) + # -- sglomni: the chunk-cap popup ------------------------------------ + + def _sglomni_cmd(self, model_id="higgs_audio_v3_tts"): + return ("convert", "sglomni", + {"model_id": model_id, "clone": None, "output_format": "mp3"}) + + def _run_sglomni_preflight(self, cmd, menu_answer): + stdscr = object() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + return_value=(["book.txt"], [("book.txt", "x")])), \ + patch.object(hub.tui, "menu", return_value=menu_answer) \ + as mk_menu: + outcome = hub._preflight(stdscr, cmd) + return outcome, mk_menu + + def test_sglomni_run_asks_the_chunk_popup_and_stashes_the_clamp(self): + cmd = self._sglomni_cmd() + outcome, mk_menu = self._run_sglomni_preflight(cmd, "clamp") + self.assertTrue(outcome) + self.assertEqual(cmd[2]["chunk_size"], 80) + options = mk_menu.call_args[0][2] + self.assertEqual([value for _label, value in options], + ["clamp", "anyway", "cancel"]) + self.assertIn("Set Chunk to 80", options[0][0]) + + def test_sglomni_run_try_anyway_stashes_no_clamp(self): + cmd = self._sglomni_cmd() + outcome, mk_menu = self._run_sglomni_preflight(cmd, "anyway") + self.assertTrue(outcome) + self.assertNotIn("chunk_size", cmd[2]) + mk_menu.assert_called_once() + + def test_sglomni_run_cancel_raises_back_to_form(self): + # Cancel backs out to the Generate form (the run never starts). + stdscr = object() + with patch.object(hub.AudiobookConverter, "preflight_overwrites", + return_value=(["book.txt"], [("book.txt", "x")])), \ + patch.object(hub.tui, "menu", return_value="cancel"): + with self.assertRaises(hub._BackToForm): + hub._preflight(stdscr, self._sglomni_cmd()) + + def test_sglomni_uncapped_model_skips_the_popup(self): + outcome, mk_menu = self._run_sglomni_preflight( + self._sglomni_cmd("zonos2"), + MagicMock(side_effect=AssertionError("should not ask"))) + self.assertTrue(outcome) + mk_menu.assert_not_called() + # -- "All (multiple generation)": one plan per model ---------------- def _all_cmd(self): diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py index 501f1f7..53f0a44 100644 --- a/app/tests/test_runview.py +++ b/app/tests/test_runview.py @@ -246,6 +246,44 @@ class StateTransitionTests(_FakeTui, unittest.TestCase): self.assertEqual(view.boot_hint, "FP8 needs compute capability 8.9+") + def test_port_taken_event_is_error_with_the_port_specifics(self): + # The launcher moved the server to a random port (configured one + # taken) and the boot was killed: the error screen names the ports + # instead of sitting on "starting" until the timeout. + view, _ = self.make_view() + view.handle_event({"kind": "starting", "name": "sglomni", + "log_path": "/tmp/sglomni-server.log"}) + view.handle_event( + {"kind": "port_taken", "name": "sglomni", + "taken": 8100, "moved": 37183, + "message": "the sglomni launcher moved the server from port " + "8100 (already in use by another process) to port " + "37183; clients poll 8100, so this boot cannot " + "become ready — stop whatever holds port 8100 " + "and start again", + "log_tail": ["Using port 37183 instead."]}) + self.assertEqual(view.phase, "error") + self.assertEqual(view.server, "error") + self.assertIn("8100", view.server_message) + self.assertIn("37183", view.server_message) + self.assertEqual(view.log_tail, ["Using port 37183 instead."]) + + def test_port_taken_boot_failure_is_recorded_in_the_dated_log(self): + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "audiobook_test.log") + view, _ = self.make_view(log_path=log_path) + view.handle_event({"kind": "starting", "name": "sglomni", + "log_path": "/tmp/sglomni-server.log"}) + view.handle_event({"kind": "port_taken", "name": "sglomni", + "taken": 8100, "moved": 37183, + "message": "moved from port 8100", + "log_tail": []}) + with open(log_path, encoding="utf-8") as logf: + text = logf.read() + self.assertIn("ERROR - moved from port 8100", text) + self.assertIn("the server's own output is in " + "/tmp/sglomni-server.log", text) + def test_boot_failure_is_recorded_in_the_dated_log(self): # A failed boot never reaches the converter, so without this the # dated log the failure pointers name would stay blank. diff --git a/app/tests/test_tts_sglomni.py b/app/tests/test_tts_sglomni.py index 85327e1..0e3a7de 100644 --- a/app/tests/test_tts_sglomni.py +++ b/app/tests/test_tts_sglomni.py @@ -191,6 +191,7 @@ class PayloadTests(unittest.TestCase): client.instructions = kwargs.get("instructions", "") client.language = "English" client._seed = None + client._kv_fit = None return client def test_speaker_payload_sends_the_preset_name(self): @@ -256,10 +257,19 @@ class PayloadTests(unittest.TestCase): def test_higgs_payload_raises_the_generation_cap(self): """Higgs's 2048-frame engine default caps a request at ~27 s - (75 fps), below a full 250-word sub-chunk.""" + (75 fps); the catalog raises it to the most its admission window + allows (~40 s after the prompt tokens).""" client = self._make_client("higgs_audio_v3_tts") payload = client._request_payload("Hello.") - self.assertEqual(payload["max_new_tokens"], 12288) + self.assertEqual(payload["max_new_tokens"], 3000) + + def test_payload_keeps_a_learned_kv_fit(self): + """A capacity learned from an admission rejection caps later + requests below the catalog value.""" + client = self._make_client("higgs_audio_v3_tts") + client._kv_fit = 2500 + self.assertEqual(client._request_payload("Hello.")["max_new_tokens"], + 2500) def test_models_without_a_cap_send_no_max_new_tokens(self): client = self._make_client("moss_tts") @@ -293,6 +303,126 @@ class RequestErrorTests(unittest.TestCase): self.assertIsInstance(error, NonRetryableTTSError) +_KV_REJECTION_BODY = json.dumps({"error": { + "message": "Request requires more tokens than the thinker KV cache " + "can hold (input_tokens=684, max_new_tokens=12288, " + "required_tokens=12972, kv_capacity=4095). Current " + "mem_fraction_static is 0.800; try setting " + "--thinker-mem-fraction-static higher.", + "type": "InternalServerError", "code": 500}}) + + +def _http_error(code: int, body: str) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + "http://127.0.0.1:8100/v1/audio/speech", code, "error", + hdrs=None, fp=io.BytesIO(body.encode("utf-8"))) + + +def _speech_response(): + response = MagicMock() + response.__enter__.return_value = response + response.read.return_value = _WAV_BYTES + return response + + +class KvAdmissionTests(unittest.TestCase): + """The KV-window admission rejection refits max_new_tokens once.""" + + def _client(self): + client = SgOmniTTSClient.__new__(SgOmniTTSClient) + from backends.sglomni.catalog import entry_by_key + client.entry = entry_by_key("higgs_audio_v3_tts") + client.api_url = "http://127.0.0.1:8100" + client.voice = None + client.ref_audio = None + client.ref_text = "" + client.instructions = "" + client.language = "English" + client._seed = None + client.chunk_size = None + client._kv_fit = None + return client + + def test_fit_is_parsed_from_the_server_message(self): + fit = self._client()._kv_admission_fit(_KV_REJECTION_BODY) + # kv_capacity 4095 - input 684 - the 64-frame margin. + self.assertEqual(fit, 3347) + + def test_fit_is_cached_for_later_requests(self): + client = self._client() + client._kv_admission_fit(_KV_REJECTION_BODY) + client._kv_admission_fit(_KV_REJECTION_BODY) + self.assertEqual(client._kv_fit, 3347) + + def test_unrelated_errors_do_not_fit(self): + client = self._client() + self.assertIsNone(client._kv_admission_fit("CUDA out of memory")) + self.assertIsNone(client._kv_fit) + + def test_a_window_below_the_floor_raises_with_guidance(self): + body = json.dumps({"error": {"message": + "Request requires more tokens than the thinker KV cache can " + "hold (input_tokens=4000, max_new_tokens=12288, " + "required_tokens=16288, kv_capacity=4095).", "code": 500}}) + with self.assertRaises(NonRetryableTTSError) as ctx: + self._client()._kv_admission_fit(body) + self.assertIn("shorter reference clip", str(ctx.exception)) + + def test_request_wav_refits_and_resends_once(self): + client = self._client() + with patch( + "converter.clients.sglomni.urllib.request.urlopen", + side_effect=[_http_error(500, _KV_REJECTION_BODY), + _speech_response()]) as mock_open: + wav = client._request_wav("Hello.") + self.assertEqual(wav, _WAV_BYTES) + self.assertEqual(mock_open.call_count, 2) + refit = json.loads(mock_open.call_args[0][0].data) + self.assertEqual(refit["max_new_tokens"], 3347) + + def test_request_wav_surfaces_a_refit_that_fails_again(self): + client = self._client() + with patch( + "converter.clients.sglomni.urllib.request.urlopen", + side_effect=[_http_error(500, _KV_REJECTION_BODY), + _http_error(500, _KV_REJECTION_BODY)]): + with self.assertRaises(RuntimeError) as ctx: + client._request_wav("Hello.") + message = str(ctx.exception) + self.assertIn("HTTP 500", message) + self.assertIn("thinker KV cache", message) + self.assertNotIsInstance(ctx.exception, NonRetryableTTSError) + + def test_request_wav_does_not_refit_other_errors(self): + client = self._client() + with patch( + "converter.clients.sglomni.urllib.request.urlopen", + side_effect=[_http_error(500, "CUDA out of memory")]): + with self.assertRaises(RuntimeError) as ctx: + client._request_wav("Hello.") + self.assertIn("CUDA out of memory", str(ctx.exception)) + self.assertIsNone(client._kv_fit) + + def test_request_wav_keeps_the_refit_across_sub_requests(self): + # A tight window (a long reference clip): the fit binds below the + # 3000-frame catalog cap, and every later request carries it. + tight_body = json.dumps({"error": {"message": + "Request requires more tokens than the thinker KV cache can " + "hold (input_tokens=1500, max_new_tokens=3000, " + "required_tokens=4500, kv_capacity=4095).", "code": 500}}) + client = self._client() + with patch( + "converter.clients.sglomni.urllib.request.urlopen", + side_effect=[_http_error(500, tight_body), + _speech_response(), + _speech_response()]) as mock_open: + client._request_wav("Hello.") + client._request_wav("Hello again.") + self.assertEqual(mock_open.call_count, 3) + second = json.loads(mock_open.call_args[0][0].data) + self.assertEqual(second["max_new_tokens"], 2531) + + class GenerateChunkTests(unittest.TestCase): """Chunk generation: WAV output, sub-chunking, bookkeeping.""" @@ -315,6 +445,8 @@ class GenerateChunkTests(unittest.TestCase): client.instructions = "" client.language = "English" client._seed = None + client.chunk_size = None + client._kv_fit = None return client def _read_wav(self, path): @@ -347,6 +479,18 @@ class GenerateChunkTests(unittest.TestCase): self.assertEqual(len(args[0]), 3) self.assertEqual(args[1], Path(result)) + def test_run_chunk_size_caps_the_sub_requests(self): + """The pre-flight clamp (a chunk_words-capped model's popup + answer) overrides CHUNK_SIZE for this run.""" + client = self._make_client() + client.chunk_size = 10 + text = " ".join(f"word{i}" for i in range(24)) + with patch.object(client, "_request_wav", + return_value=_WAV_BYTES) as mock_wav, \ + patch("converter.clients.sglomni.concat_audio_files"): + client.generate_chunk(text, 1) + self.assertEqual(mock_wav.call_count, 3) + def test_single_subchunk_skips_concatenation(self): client = self._make_client() with patch.object(client, "_request_wav", return_value=_WAV_BYTES), \ diff --git a/app/ui/hub.py b/app/ui/hub.py index 13f17e7..7a6bfcc 100644 --- a/app/ui/hub.py +++ b/app/ui/hub.py @@ -1133,6 +1133,32 @@ def _tui_confirm(stdscr) -> Callable: return confirm +def _tui_chunk_clamp(stdscr, entry) -> Optional[int]: + """The chunk-cap popup for an sglomni run (None = no clamp). + + A model whose catalog entry caps a sub-request below CHUNK_SIZE + (Higgs: the server pins each request's prompt plus generation at a + fixed window) asks before the run: clamp CHUNK_SIZE for this run, + keep it (audio may cut off mid-chunk), or go back to the form. The + answer rides the command's kwargs (``chunk_size``) so the converter + and its client see it; the config.CHUNK_SIZE setting itself is never + rewritten. + """ + if not converter_mod.chunk_clamp_needed(entry): + return None + words = entry.chunk_words + answer = tui.menu( + stdscr, "Generation cap", + [(f"Set Chunk to {words} (this run)", "clamp"), + ("Try anyway", "anyway"), + ("Cancel", "cancel")], + help_lines=converter_mod.chunk_clamp_message(entry), + back_value="cancel") + if answer == "cancel": + raise _BackToForm() + return words if answer == "clamp" else None + + def _check_preflight_plan(stdscr, book_files: list, planned: dict) -> bool: """The shared nothing-to-convert flashes; True when there is a plan. @@ -1180,6 +1206,12 @@ def _preflight(stdscr, cmd: tuple) -> bool: confirm=_tui_confirm(stdscr)) if not _check_preflight_plan(stdscr, book_files, {"": planned}): return False + if backend == BACKEND_SGLOMNI: + entry = sglomni_backend.entry_by_key((kwargs.get("model_id") + or "").strip()) + clamp = _tui_chunk_clamp(stdscr, entry) + if clamp is not None: + kwargs["chunk_size"] = clamp kwargs["book_files"] = book_files kwargs["planned"] = planned return True diff --git a/app/ui/runview.py b/app/ui/runview.py index 4fcaeb1..73ad315 100644 --- a/app/ui/runview.py +++ b/app/ui/runview.py @@ -193,6 +193,19 @@ class RunView(ScreenView): self.boot_hint = event.get("hint") or "" self._record_boot_failure() self._finish("error") + elif kind == "port_taken": + # The launcher moved the server to a random port (the + # configured one was taken) and the boot was killed as + # unreachable: same terminal treatment as a crash, with the + # port specifics as the message. + self.server = "error" + self.server_message = str(event.get("message") + or "the server moved itself to " + "another port") + self.log_tail = list(event.get("log_tail") or []) + self.boot_hint = event.get("hint") or "" + self._record_boot_failure() + self._finish("error") elif kind == "cancelled": self.cancelled = True if self.server in ("starting", "ready", "processing"): diff --git a/audiobook.py b/audiobook.py index 88e8dca..302bb17 100755 --- a/audiobook.py +++ b/audiobook.py @@ -259,7 +259,7 @@ def convert(backend: str, voice: str = None, clone: str = None, input_file: Path = None, output_file: Path = None, progress=None, cancel=None, confirm=None, book_files=None, planned=None, manage_server: bool = False, - model_ids=None, model_voices=None, + chunk_size: int = None, model_ids=None, model_voices=None, planned_by_model=None) -> int: """Run one conversion pass with explicit options (used by the CLI and hub). @@ -382,6 +382,17 @@ def convert(backend: str, voice: str = None, clone: str = None, model_id = sg_entry.key voice_mode = voice_mode_for(backend, voice, clone, instructions, model=model_id) + if planned is None: + # CLI runs pre-flight here: a model whose engine caps one + # request below a full CHUNK_SIZE sub-chunk (Higgs) gets the + # clamp popup before anything converts; Cancel stops the run + # unstarted. Hub runs asked in the TUI pre-flight and carry + # the answer as chunk_size. + try: + chunk_size = _converter_mod.prompt_chunk_clamp(sg_entry) + except _converter_mod.ChunkClampCancelled: + print("[INFO] Conversion cancelled; nothing was converted") + return 0 else: # qwen: instructions design the voice (VoiceDesign model), a # reference .wav clones one (Base), otherwise a built-in speaker. @@ -435,6 +446,7 @@ def convert(backend: str, voice: str = None, clone: str = None, language=language, backend=backend, voice=voice, debug=debug, model_id=model_id, instructions=instructions, request_options=request_options, api_url=api_url, + chunk_size=chunk_size, progress=progress, cancel=cancel, ) converter._book_files = book_files |
