1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
|
"""Client for the SGLang-Omni OpenAI-compatible TTS server.
SGLang-Omni (``sgl-omni serve --model-path <hf-repo>``) hosts one TTS
model per process behind the OpenAI-style ``/v1/audio/speech`` endpoint.
This client speaks that endpoint for every catalog model, resolving the
request shape from the model's voice capability (``backends.sglomni.
catalog``):
speaker the voice names a preset shipped with the model (Qwen3-TTS
CustomVoice speakers; Voxtral preset voices)
clone the voice comes from a reference clip sent per request as
``ref_audio`` + ``ref_text``. The reference is transcribed
with a local Whisper backend when no transcript is given (the
qwen backend's flow). On a loopback server the clip travels
as a local path the server reads directly; anywhere else it
is inlined as a base64 data URL, so ``--api-url`` remote
servers work without any server-side file setup.
design the voice is described by instructions (``task_type=
"VoiceDesign"`` + ``instructions``, Qwen3-TTS VoiceDesign).
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).
"""
import base64
import json
import logging
import re
import tempfile
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import List, Optional
from .. import config
from ..audio import concat_audio_files
from ..chunking import split_into_chunks
from .base import (BaseTTSClient, ConversionCancelled,
NonRetryableTTSError, resolve_request_seed)
from .languages import normalize_language
logger = logging.getLogger(__name__)
# Response formats the endpoint offers; complete WAV files need no
# sample-rate handling client-side (the header carries it, and models
# differ: 24 kHz Voxtral/Higgs, 44.1 kHz ZONOS2, 48 kHz MOSS Local).
RESPONSE_FORMAT = "wav"
# The voice name the server synthesizes with when the request does not
# pick a preset or clone a reference.
DEFAULT_VOICE = "default"
# Mimetypes for inlined reference audio (data URLs), by file suffix.
_MIME_BY_SUFFIX = {
".wav": "audio/wav", ".mp3": "audio/mpeg", ".flac": "audio/flac",
".ogg": "audio/ogg", ".aac": "audio/aac", ".m4a": "audio/mp4",
".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
# 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
reference files by path)."""
try:
host = urllib.parse.urlsplit(url).hostname or "127.0.0.1"
except ValueError:
return False
return host in ("127.0.0.1", "localhost", "::1")
def _data_url(path: Path) -> str:
"""PATH's audio bytes as a base64 data URL (for remote servers)."""
mime = _MIME_BY_SUFFIX.get(path.suffix.lower(), "audio/wav")
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
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."""
def __init__(self, chunks_dir: Path,
model: Optional[str] = None,
voice: Optional[str] = None,
ref_audio: Optional[str] = None,
ref_text: Optional[str] = None,
skip_transcription: bool = False,
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
# 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(
f"Unknown SGLang-Omni model {model!r} — pick a catalog key "
"(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()
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
# 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).
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:
language = config.LANGUAGE
self.language = normalize_language(language)
self._check_connect_inputs()
self._connect()
# ------------------------------------------------------------------
# Connection
# ------------------------------------------------------------------
def _check_connect_inputs(self) -> None:
"""Validate the voice inputs against the model's capability."""
entry = self.entry
if entry.capability == "design" and not self.instructions:
raise RuntimeError(
f"{entry.label} designs the voice from an instruction: "
'pass --instructions "..." describing the voice.')
if entry.capability == "clone" and entry.requires_reference \
and not self.ref_audio:
raise RuntimeError(
f"{entry.label} requires reference audio to narrate: "
"pass --clone PATH (a .wav reference clip), or pick a "
"model that synthesizes without one.")
if entry.capability == "speaker" and self.ref_audio:
self._report(f"[WARNING] --clone is ignored with {entry.label}: "
"it voices text with its built-in presets.")
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:
self._report(
f"[WARNING] Voice {self.voice!r} is not one of "
f"{entry.label}'s presets ({', '.join(entry.speakers)}); "
"the server will reject it if it does not know the name.")
def _connect(self) -> None:
"""Verify the server is up, healthy, and hosting the expected model.
The managed-server lifecycle (managed.ensure_running / the run
view's autostart) normally boots exactly the selected model; a
foreign server hosting something else — or a remote one the form
could not classify — fails here with both model names instead of
producing per-chunk failures later.
"""
entry, url = self.entry, self.api_url
try:
payload = self._fetch_json("/health", timeout=10)
except Exception as exc:
raise RuntimeError(
f"SGLang-Omni server not reachable at {url}: {exc}. 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
if not isinstance(payload, dict) \
or payload.get("status") != "healthy":
raise RuntimeError(
f"The SGLang-Omni server at {url} is not healthy yet "
f"(health: {payload}). Wait for it to finish booting and "
"retry.")
served = self._served_model()
if served is not None and served != entry.repo:
raise RuntimeError(
f"The SGLang-Omni server at {url} hosts {served}, but "
f"this run selected {entry.repo}. Restart it with that "
"model (the managed server restarts automatically), or "
"pick the hosted model for this run.")
self._resolve_reference_text()
mode = {"speaker": "built-in presets",
"clone": "voice cloning",
"design": "voice design"}[entry.capability]
self._report(f"[OK] Connected to SGLang-Omni at {url} "
f"({entry.label}, {mode})")
def _served_model(self) -> Optional[str]:
"""The repo id the server hosts (None when it cannot be read)."""
try:
payload = self._get_json("/v1/models", timeout=10)
except Exception:
return None
entries = (payload or {}).get("data")
if isinstance(entries, list) and entries \
and isinstance(entries[0], dict):
return entries[0].get("id")
return None
def _resolve_reference_text(self) -> None:
"""Resolve the clone reference transcript: explicit text, then a
local Whisper transcription."""
if self.entry.capability != "clone" or not self.ref_audio:
return
if not self.ref_text and not self.skip_transcription:
self._report("[INFO] Transcribing reference audio for voice "
"cloning...")
from .transcribe import transcribe_reference_audio
self.ref_text = transcribe_reference_audio(self.ref_audio) or ""
if self.ref_text:
self._report(f"[OK] Reference text: {self.ref_text}")
else:
self._report("[WARNING] No reference transcript: cloning runs "
"without ref_text, which lowers quality for "
"models that use it. Pass --transcription \"...\" "
"for best results.")
# ------------------------------------------------------------------
# HTTP requests
# ------------------------------------------------------------------
def _fetch_json(self, path: str, timeout: int = 10) -> dict:
"""GET PATH and parse the JSON body, raising on connection errors.
Unlike _get_json this surfaces unreachable servers to the caller —
the connect flow needs to tell "nothing is listening" (start the
server) apart from "listening but still booting" (wait).
"""
url = f"{self.api_url}{path}"
with urllib.request.urlopen(url, timeout=timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
return payload if isinstance(payload, dict) else {}
def _get_json(self, path: str, timeout: int = 10) -> Optional[dict]:
"""GET PATH and parse a JSON object, or None on any error."""
try:
return self._fetch_json(path, timeout=timeout)
except (OSError, ValueError):
return None
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)
def _request_payload(self, text: str) -> dict:
"""The /v1/audio/speech JSON body for one sub-chunk."""
entry = self.entry
payload = {
"model": entry.repo,
"voice": self.voice or DEFAULT_VOICE,
"input": text,
"response_format": RESPONSE_FORMAT,
"language": self.language,
}
if self._seed is not None:
payload["seed"] = self._seed
if entry.max_new_tokens is not None:
# 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. 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
elif entry.capability == "clone" and self.ref_audio:
payload["ref_audio"] = self._ref_audio_value()
if self.ref_text:
payload["ref_text"] = self.ref_text
return payload
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"
request = urllib.request.Request(
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:
# 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
if not wav:
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.
A 4xx envelope (BadRequestError et al.) is deterministic — the
identical request fails identically on every attempt — so it
surfaces as NonRetryableTTSError and the chunk loop aborts with
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):
return NonRetryableTTSError(
f"SGLang-Omni rejected the request (HTTP {status}): "
f"{message}")
return RuntimeError(
f"SGLang-Omni server returned HTTP {status}: {message}")
# ------------------------------------------------------------------
# Chunk generation
# ------------------------------------------------------------------
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 ``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
the returned WAV files are concatenated into one chunk file.
"""
try:
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")
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):
part = Path(parts_dir) / f"part_{index:02d}.wav"
part.write_bytes(wav)
part_paths.append(part)
concat_audio_files(part_paths, output_path)
logger.debug("Chunk %d generated (%d sub-request(s))",
chunk_num, len(wav_parts))
return str(output_path)
except ConversionCancelled:
raise
except Exception as exc:
logger.error("SGLang-Omni chunk processing failed for chunk "
"%d: %s", chunk_num, exc)
return None
|