omnius 1.0.606 → 1.0.607
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/assets/voice-defaults/Announcer_testchamber03.wav +0 -0
- package/assets/voicebox/chatterbox-generation-serialization.patch +1014 -0
- package/dist/index.js +4177 -2856
- package/docs/DISCOVERY.json +76 -0
- package/docs/DISCOVERY.md +2 -0
- package/docs/reference/rest-api.md +11 -1
- package/docs/reference/slash-commands.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
diff --git a/backend/app.py b/backend/app.py
|
|
2
|
+
index ea8411c..e77df60 100644
|
|
3
|
+
--- a/backend/app.py
|
|
4
|
+
+++ b/backend/app.py
|
|
5
|
+
@@ -103,6 +103,32 @@ if not os.environ.get("MIOPEN_LOG_LEVEL"):
|
|
6
|
+
os.environ["MIOPEN_LOG_LEVEL"] = "4"
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
+
|
|
10
|
+
+
|
|
11
|
+
+def _bounded_torch_thread_env(name: str, fallback: int, maximum: int) -> int:
|
|
12
|
+
+ """Read one bounded PyTorch pool size from the process environment."""
|
|
13
|
+
+ try:
|
|
14
|
+
+ configured = int(os.environ.get(name, str(fallback)) or fallback)
|
|
15
|
+
+ except (TypeError, ValueError):
|
|
16
|
+
+ configured = fallback
|
|
17
|
+
+ return min(maximum, max(1, configured))
|
|
18
|
+
+
|
|
19
|
+
+
|
|
20
|
+
+_voicebox_cpu_threads = _bounded_torch_thread_env("VOICEBOX_CPU_THREADS", 4, 32)
|
|
21
|
+
+_voicebox_interop_threads = _bounded_torch_thread_env("VOICEBOX_INTEROP_THREADS", 1, 8)
|
|
22
|
+
+torch.set_num_threads(_voicebox_cpu_threads)
|
|
23
|
+
+try:
|
|
24
|
+
+ torch.set_num_interop_threads(_voicebox_interop_threads)
|
|
25
|
+
+except RuntimeError as error:
|
|
26
|
+
+ logger.warning(
|
|
27
|
+
+ "PyTorch inter-op thread pool was already initialized; keeping current setting: %s",
|
|
28
|
+
+ error,
|
|
29
|
+
+ )
|
|
30
|
+
+logger.info(
|
|
31
|
+
+ "PyTorch thread pools bounded: intra-op=%d inter-op=%d",
|
|
32
|
+
+ _voicebox_cpu_threads,
|
|
33
|
+
+ _voicebox_interop_threads,
|
|
34
|
+
+)
|
|
35
|
+
from fastapi import FastAPI
|
|
36
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
37
|
+
from urllib.parse import quote
|
|
38
|
+
@@ -112,7 +138,7 @@ from .services import tts, transcribe, llm
|
|
39
|
+
from .database import get_db
|
|
40
|
+
from .utils.platform_detect import get_backend_type
|
|
41
|
+
from .utils.progress import get_progress_manager
|
|
42
|
+
-from .services.task_queue import create_background_task, init_queue
|
|
43
|
+
+from .services.task_queue import create_background_task, init_queue, shutdown_queue
|
|
44
|
+
from .routes import register_routers
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@@ -359,6 +385,10 @@ async def _run_startup(application: FastAPI) -> None:
|
|
48
|
+
async def _run_shutdown() -> None:
|
|
49
|
+
"""Unload models on lifespan exit."""
|
|
50
|
+
logger.info("Voicebox server shutting down...")
|
|
51
|
+
+ try:
|
|
52
|
+
+ await shutdown_queue()
|
|
53
|
+
+ except Exception:
|
|
54
|
+
+ logger.exception("Failed to stop generation workers")
|
|
55
|
+
try:
|
|
56
|
+
tts.unload_tts_model()
|
|
57
|
+
except Exception:
|
|
58
|
+
diff --git a/backend/backends/chatterbox_backend.py b/backend/backends/chatterbox_backend.py
|
|
59
|
+
index e7a025b..674919d 100644
|
|
60
|
+
--- a/backend/backends/chatterbox_backend.py
|
|
61
|
+
+++ b/backend/backends/chatterbox_backend.py
|
|
62
|
+
@@ -48,6 +48,10 @@ class ChatterboxTTSBackend:
|
|
63
|
+
self.model_size = "default"
|
|
64
|
+
self._device = None
|
|
65
|
+
self._model_load_lock = asyncio.Lock()
|
|
66
|
+
+ # Generation installs attention hooks on the shared model. The lock
|
|
67
|
+
+ # lives in the worker thread so task cancellation cannot release it
|
|
68
|
+
+ # while asyncio.to_thread() is still executing.
|
|
69
|
+
+ self._generation_lock = threading.Lock()
|
|
70
|
+
|
|
71
|
+
def _get_device(self) -> str:
|
|
72
|
+
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
|
|
73
|
+
@@ -198,20 +202,21 @@ class ChatterboxTTSBackend:
|
|
74
|
+
def _generate_sync():
|
|
75
|
+
import torch
|
|
76
|
+
|
|
77
|
+
- if seed is not None:
|
|
78
|
+
- manual_seed(seed, self._device)
|
|
79
|
+
+ with self._generation_lock:
|
|
80
|
+
+ if seed is not None:
|
|
81
|
+
+ manual_seed(seed, self._device)
|
|
82
|
+
|
|
83
|
+
- logger.info(f"[Chatterbox] Generating: lang={language}")
|
|
84
|
+
+ logger.info(f"[Chatterbox] Generating: lang={language}")
|
|
85
|
+
|
|
86
|
+
- wav = self.model.generate(
|
|
87
|
+
- text,
|
|
88
|
+
- language_id=language,
|
|
89
|
+
- audio_prompt_path=ref_audio,
|
|
90
|
+
- exaggeration=lang_defaults["exaggeration"],
|
|
91
|
+
- cfg_weight=lang_defaults["cfg_weight"],
|
|
92
|
+
- temperature=lang_defaults["temperature"],
|
|
93
|
+
- repetition_penalty=lang_defaults["repetition_penalty"],
|
|
94
|
+
- )
|
|
95
|
+
+ wav = self.model.generate(
|
|
96
|
+
+ text,
|
|
97
|
+
+ language_id=language,
|
|
98
|
+
+ audio_prompt_path=ref_audio,
|
|
99
|
+
+ exaggeration=lang_defaults["exaggeration"],
|
|
100
|
+
+ cfg_weight=lang_defaults["cfg_weight"],
|
|
101
|
+
+ temperature=lang_defaults["temperature"],
|
|
102
|
+
+ repetition_penalty=lang_defaults["repetition_penalty"],
|
|
103
|
+
+ )
|
|
104
|
+
|
|
105
|
+
# Convert tensor -> numpy
|
|
106
|
+
if isinstance(wav, torch.Tensor):
|
|
107
|
+
@@ -223,4 +228,14 @@ class ChatterboxTTSBackend:
|
|
108
|
+
|
|
109
|
+
return audio, sample_rate
|
|
110
|
+
|
|
111
|
+
- return await asyncio.to_thread(_generate_sync)
|
|
112
|
+
+ physical_generation = asyncio.create_task(asyncio.to_thread(_generate_sync))
|
|
113
|
+
+ try:
|
|
114
|
+
+ return await asyncio.shield(physical_generation)
|
|
115
|
+
+ except asyncio.CancelledError:
|
|
116
|
+
+ # Cancelling to_thread() cannot stop its worker. Drain it before
|
|
117
|
+
+ # propagating cancellation so the queue cannot overlap models.
|
|
118
|
+
+ try:
|
|
119
|
+
+ await asyncio.shield(physical_generation)
|
|
120
|
+
+ except Exception:
|
|
121
|
+
+ logger.exception("Chatterbox generation failed while draining cancellation")
|
|
122
|
+
+ raise
|
|
123
|
+
diff --git a/backend/backends/chatterbox_turbo_backend.py b/backend/backends/chatterbox_turbo_backend.py
|
|
124
|
+
index 6f7d6b9..8e10011 100644
|
|
125
|
+
--- a/backend/backends/chatterbox_turbo_backend.py
|
|
126
|
+
+++ b/backend/backends/chatterbox_turbo_backend.py
|
|
127
|
+
@@ -8,7 +8,9 @@ Forces CPU on macOS due to known MPS tensor issues.
|
|
128
|
+
|
|
129
|
+
import asyncio
|
|
130
|
+
import logging
|
|
131
|
+
+import os
|
|
132
|
+
import threading
|
|
133
|
+
+from contextlib import nullcontext
|
|
134
|
+
from pathlib import Path
|
|
135
|
+
from typing import ClassVar, List, Optional, Tuple
|
|
136
|
+
|
|
137
|
+
@@ -37,6 +39,20 @@ _TURBO_WEIGHT_FILES = [
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
+def _bounded_replica_count() -> int:
|
|
142
|
+
+ """Return the bounded number of isolated Turbo model replicas.
|
|
143
|
+
+
|
|
144
|
+
+ Chatterbox mutates model-local attention state while generating, so one
|
|
145
|
+
+ model object must never serve overlapping calls. Parallelism is provided
|
|
146
|
+
+ by independent replicas instead of weakening that safety boundary.
|
|
147
|
+
+ """
|
|
148
|
+
+ try:
|
|
149
|
+
+ configured = int(os.environ.get("VOICEBOX_CHATTERBOX_TURBO_REPLICAS", "2") or 2)
|
|
150
|
+
+ except (TypeError, ValueError):
|
|
151
|
+
+ configured = 2
|
|
152
|
+
+ return min(4, max(1, configured))
|
|
153
|
+
+
|
|
154
|
+
+
|
|
155
|
+
class ChatterboxTurboTTSBackend:
|
|
156
|
+
"""Chatterbox Turbo TTS backend — fast, English-only, with paralinguistic tags."""
|
|
157
|
+
|
|
158
|
+
@@ -48,12 +64,21 @@ class ChatterboxTurboTTSBackend:
|
|
159
|
+
self.model_size = "default"
|
|
160
|
+
self._device = None
|
|
161
|
+
self._model_load_lock = asyncio.Lock()
|
|
162
|
+
+ self._replica_count = _bounded_replica_count()
|
|
163
|
+
+ self._replicas = []
|
|
164
|
+
+ self._available_replicas = None
|
|
165
|
+
+ self._seeded_generation_lock = threading.Lock()
|
|
166
|
+
+ self._active_replica_ids = set()
|
|
167
|
+
+ self._unload_requested = False
|
|
168
|
+
|
|
169
|
+
def _get_device(self) -> str:
|
|
170
|
+
return get_torch_device(force_cpu_on_mac=True, allow_xpu=True)
|
|
171
|
+
|
|
172
|
+
def is_loaded(self) -> bool:
|
|
173
|
+
- return self.model is not None
|
|
174
|
+
+ return (
|
|
175
|
+
+ len(self._replicas) == self._replica_count
|
|
176
|
+
+ and self._available_replicas is not None
|
|
177
|
+
+ )
|
|
178
|
+
|
|
179
|
+
def _get_model_path(self, model_size: str = "default") -> str:
|
|
180
|
+
return CHATTERBOX_TURBO_HF_REPO
|
|
181
|
+
@@ -62,23 +87,69 @@ class ChatterboxTurboTTSBackend:
|
|
182
|
+
return is_model_cached(CHATTERBOX_TURBO_HF_REPO, required_files=_TURBO_WEIGHT_FILES)
|
|
183
|
+
|
|
184
|
+
async def load_model(self, model_size: str = "default") -> None:
|
|
185
|
+
- """Load the Chatterbox Turbo model."""
|
|
186
|
+
- if self.model is not None:
|
|
187
|
+
+ """Load every configured Chatterbox Turbo model replica."""
|
|
188
|
+
+ if self.is_loaded():
|
|
189
|
+
return
|
|
190
|
+
async with self._model_load_lock:
|
|
191
|
+
- if self.model is not None:
|
|
192
|
+
+ if self.is_loaded():
|
|
193
|
+
return
|
|
194
|
+
- await asyncio.to_thread(self._load_model_sync)
|
|
195
|
+
-
|
|
196
|
+
- def _load_model_sync(self):
|
|
197
|
+
- """Synchronous model loading."""
|
|
198
|
+
+ while len(self._replicas) < self._replica_count:
|
|
199
|
+
+ replica_index = len(self._replicas)
|
|
200
|
+
+ physical_load = asyncio.create_task(
|
|
201
|
+
+ asyncio.to_thread(
|
|
202
|
+
+ self._load_model_sync,
|
|
203
|
+
+ replica_index,
|
|
204
|
+
+ )
|
|
205
|
+
+ )
|
|
206
|
+
+ cancelled = False
|
|
207
|
+
+ while True:
|
|
208
|
+
+ try:
|
|
209
|
+
+ model, device, cuda_stream = await asyncio.shield(physical_load)
|
|
210
|
+
+ break
|
|
211
|
+
+ except asyncio.CancelledError:
|
|
212
|
+
+ # asyncio cannot stop a native model load. Keep the
|
|
213
|
+
+ # load lock leased, retain its resulting model/VRAM,
|
|
214
|
+
+ # and only then propagate cancellation.
|
|
215
|
+
+ cancelled = True
|
|
216
|
+
+ if physical_load.cancelled():
|
|
217
|
+
+ raise
|
|
218
|
+
+ self._replicas.append({
|
|
219
|
+
+ "id": replica_index,
|
|
220
|
+
+ "model": model,
|
|
221
|
+
+ "device": device,
|
|
222
|
+
+ "cuda_stream": cuda_stream,
|
|
223
|
+
+ })
|
|
224
|
+
+ if cancelled:
|
|
225
|
+
+ if len(self._replicas) == self._replica_count:
|
|
226
|
+
+ self._publish_replica_pool()
|
|
227
|
+
+ raise asyncio.CancelledError
|
|
228
|
+
+
|
|
229
|
+
+ self._publish_replica_pool()
|
|
230
|
+
+
|
|
231
|
+
+ def _publish_replica_pool(self) -> None:
|
|
232
|
+
+ """Publish a pool only when every physical replica is retained."""
|
|
233
|
+
+ if len(self._replicas) != self._replica_count:
|
|
234
|
+
+ return
|
|
235
|
+
+ available = asyncio.Queue(maxsize=self._replica_count)
|
|
236
|
+
+ for replica in self._replicas:
|
|
237
|
+
+ available.put_nowait(replica)
|
|
238
|
+
+ self._available_replicas = available
|
|
239
|
+
+ self.model = self._replicas[0]["model"]
|
|
240
|
+
+ self._device = self._replicas[0]["device"]
|
|
241
|
+
+
|
|
242
|
+
+ def _load_model_sync(self, replica_index: int = 0):
|
|
243
|
+
+ """Synchronously construct one physically independent model replica."""
|
|
244
|
+
model_name = "chatterbox-turbo"
|
|
245
|
+
is_cached = self._is_model_cached()
|
|
246
|
+
|
|
247
|
+
with model_load_progress(model_name, is_cached):
|
|
248
|
+
device = self._get_device()
|
|
249
|
+
- self._device = device
|
|
250
|
+
- logger.info(f"Loading Chatterbox Turbo TTS on {device}...")
|
|
251
|
+
+ logger.info(
|
|
252
|
+
+ "Loading Chatterbox Turbo TTS replica %d/%d on %s...",
|
|
253
|
+
+ replica_index + 1,
|
|
254
|
+
+ self._replica_count,
|
|
255
|
+
+ device,
|
|
256
|
+
+ )
|
|
257
|
+
|
|
258
|
+
import torch
|
|
259
|
+
from huggingface_hub import snapshot_download
|
|
260
|
+
@@ -107,19 +178,54 @@ class ChatterboxTurboTTSBackend:
|
|
261
|
+
model = ChatterboxTurboTTS.from_local(local_path, device)
|
|
262
|
+
|
|
263
|
+
patch_chatterbox_f32(model)
|
|
264
|
+
- self.model = model
|
|
265
|
+
-
|
|
266
|
+
- logger.info("Chatterbox Turbo TTS loaded successfully")
|
|
267
|
+
+ cuda_stream = None
|
|
268
|
+
+ if str(device).startswith("cuda") and torch.cuda.is_available():
|
|
269
|
+
+ # PyTorch's default stream can serialize otherwise independent
|
|
270
|
+
+ # host threads. Give each model replica an explicit stream so
|
|
271
|
+
+ # kernels from separate live calls may overlap safely.
|
|
272
|
+
+ cuda_stream = torch.cuda.Stream(device=device)
|
|
273
|
+
+
|
|
274
|
+
+ logger.info(
|
|
275
|
+
+ "Chatterbox Turbo TTS replica %d/%d loaded successfully",
|
|
276
|
+
+ replica_index + 1,
|
|
277
|
+
+ self._replica_count,
|
|
278
|
+
+ )
|
|
279
|
+
+ return model, device, cuda_stream
|
|
280
|
+
|
|
281
|
+
def unload_model(self) -> None:
|
|
282
|
+
- """Unload model to free memory."""
|
|
283
|
+
- if self.model is not None:
|
|
284
|
+
- device = self._device
|
|
285
|
+
- del self.model
|
|
286
|
+
- self.model = None
|
|
287
|
+
- self._device = None
|
|
288
|
+
+ """Unload replicas only after every physical generation releases one."""
|
|
289
|
+
+ if self._active_replica_ids:
|
|
290
|
+
+ self._unload_requested = True
|
|
291
|
+
+ logger.info(
|
|
292
|
+
+ "Deferring Chatterbox Turbo unload until %d active replica(s) finish",
|
|
293
|
+
+ len(self._active_replica_ids),
|
|
294
|
+
+ )
|
|
295
|
+
+ return
|
|
296
|
+
+ self._unload_replicas()
|
|
297
|
+
+
|
|
298
|
+
+ def _unload_replicas(self) -> None:
|
|
299
|
+
+ devices = {replica["device"] for replica in self._replicas}
|
|
300
|
+
+ self._available_replicas = None
|
|
301
|
+
+ self._replicas.clear()
|
|
302
|
+
+ self.model = None
|
|
303
|
+
+ self._device = None
|
|
304
|
+
+ self._unload_requested = False
|
|
305
|
+
+ for device in devices:
|
|
306
|
+
empty_device_cache(device)
|
|
307
|
+
- logger.info("Chatterbox Turbo unloaded")
|
|
308
|
+
+ logger.info("Chatterbox Turbo replicas unloaded")
|
|
309
|
+
+
|
|
310
|
+
+ def generation_capacity(self) -> dict:
|
|
311
|
+
+ """Expose bounded physical capacity for readiness/health adapters."""
|
|
312
|
+
+ return {
|
|
313
|
+
+ "configured": self._replica_count,
|
|
314
|
+
+ "loaded": len(self._replicas),
|
|
315
|
+
+ "active": len(self._active_replica_ids),
|
|
316
|
+
+ "available": (
|
|
317
|
+
+ self._available_replicas.qsize()
|
|
318
|
+
+ if self._available_replicas is not None
|
|
319
|
+
+ else 0
|
|
320
|
+
+ ),
|
|
321
|
+
+ }
|
|
322
|
+
|
|
323
|
+
async def create_voice_prompt(
|
|
324
|
+
self,
|
|
325
|
+
@@ -171,36 +277,85 @@ class ChatterboxTurboTTSBackend:
|
|
326
|
+
"""
|
|
327
|
+
await self.load_model()
|
|
328
|
+
|
|
329
|
+
- ref_audio = voice_prompt.get("ref_audio")
|
|
330
|
+
- if ref_audio and not Path(ref_audio).exists():
|
|
331
|
+
- logger.warning(f"Reference audio not found: {ref_audio}")
|
|
332
|
+
- ref_audio = None
|
|
333
|
+
-
|
|
334
|
+
- def _generate_sync():
|
|
335
|
+
- import torch
|
|
336
|
+
-
|
|
337
|
+
- if seed is not None:
|
|
338
|
+
- manual_seed(seed, self._device)
|
|
339
|
+
-
|
|
340
|
+
- logger.info("[Chatterbox Turbo] Generating (English)")
|
|
341
|
+
-
|
|
342
|
+
- wav = self.model.generate(
|
|
343
|
+
- text,
|
|
344
|
+
- audio_prompt_path=ref_audio,
|
|
345
|
+
- temperature=0.8,
|
|
346
|
+
- top_k=1000,
|
|
347
|
+
- top_p=0.95,
|
|
348
|
+
- repetition_penalty=1.2,
|
|
349
|
+
- )
|
|
350
|
+
-
|
|
351
|
+
- # Convert tensor -> numpy
|
|
352
|
+
- if isinstance(wav, torch.Tensor):
|
|
353
|
+
- audio = wav.squeeze().cpu().numpy().astype(np.float32)
|
|
354
|
+
- else:
|
|
355
|
+
- audio = np.asarray(wav, dtype=np.float32)
|
|
356
|
+
-
|
|
357
|
+
- sample_rate = getattr(self.model, "sr", None) or getattr(self.model, "sample_rate", 24000)
|
|
358
|
+
-
|
|
359
|
+
- return audio, sample_rate
|
|
360
|
+
-
|
|
361
|
+
- return await asyncio.to_thread(_generate_sync)
|
|
362
|
+
+ available = self._available_replicas
|
|
363
|
+
+ if available is None:
|
|
364
|
+
+ raise RuntimeError("Chatterbox Turbo replica pool is not ready")
|
|
365
|
+
+ replica = await available.get()
|
|
366
|
+
+ replica_id = replica["id"]
|
|
367
|
+
+ self._active_replica_ids.add(replica_id)
|
|
368
|
+
+ try:
|
|
369
|
+
+ ref_audio = voice_prompt.get("ref_audio")
|
|
370
|
+
+ if ref_audio and not Path(ref_audio).exists():
|
|
371
|
+
+ logger.warning(f"Reference audio not found: {ref_audio}")
|
|
372
|
+
+ ref_audio = None
|
|
373
|
+
+
|
|
374
|
+
+ def _generate_sync():
|
|
375
|
+
+ import torch
|
|
376
|
+
+
|
|
377
|
+
+ # A supplied seed controls process-global RNG state in the
|
|
378
|
+
+ # upstream model. Preserve deterministic seeded calls by
|
|
379
|
+
+ # serializing only those calls; ordinary live calls use
|
|
380
|
+
+ # independent replicas concurrently.
|
|
381
|
+
+ seed_guard = (
|
|
382
|
+
+ self._seeded_generation_lock
|
|
383
|
+
+ if seed is not None
|
|
384
|
+
+ else nullcontext()
|
|
385
|
+
+ )
|
|
386
|
+
+ with seed_guard:
|
|
387
|
+
+ if seed is not None:
|
|
388
|
+
+ manual_seed(seed, replica["device"])
|
|
389
|
+
+
|
|
390
|
+
+ logger.info(
|
|
391
|
+
+ "[Chatterbox Turbo] Generating on replica %d/%d (English)",
|
|
392
|
+
+ replica_id + 1,
|
|
393
|
+
+ self._replica_count,
|
|
394
|
+
+ )
|
|
395
|
+
+
|
|
396
|
+
+ stream = replica["cuda_stream"]
|
|
397
|
+
+ stream_guard = (
|
|
398
|
+
+ torch.cuda.stream(stream)
|
|
399
|
+
+ if stream is not None
|
|
400
|
+
+ else nullcontext()
|
|
401
|
+
+ )
|
|
402
|
+
+ with stream_guard:
|
|
403
|
+
+ wav = replica["model"].generate(
|
|
404
|
+
+ text,
|
|
405
|
+
+ audio_prompt_path=ref_audio,
|
|
406
|
+
+ temperature=0.8,
|
|
407
|
+
+ top_k=1000,
|
|
408
|
+
+ top_p=0.95,
|
|
409
|
+
+ repetition_penalty=1.2,
|
|
410
|
+
+ )
|
|
411
|
+
+ if stream is not None:
|
|
412
|
+
+ stream.synchronize()
|
|
413
|
+
+
|
|
414
|
+
+ # Convert tensor -> numpy
|
|
415
|
+
+ if isinstance(wav, torch.Tensor):
|
|
416
|
+
+ audio = wav.squeeze().cpu().numpy().astype(np.float32)
|
|
417
|
+
+ else:
|
|
418
|
+
+ audio = np.asarray(wav, dtype=np.float32)
|
|
419
|
+
+
|
|
420
|
+
+ model = replica["model"]
|
|
421
|
+
+ sample_rate = getattr(model, "sr", None) or getattr(model, "sample_rate", 24000)
|
|
422
|
+
+
|
|
423
|
+
+ return audio, sample_rate
|
|
424
|
+
+
|
|
425
|
+
+ physical_generation = asyncio.create_task(asyncio.to_thread(_generate_sync))
|
|
426
|
+
+ try:
|
|
427
|
+
+ return await asyncio.shield(physical_generation)
|
|
428
|
+
+ except asyncio.CancelledError:
|
|
429
|
+
+ # asyncio cannot stop an in-flight worker thread. Keep this
|
|
430
|
+
+ # replica leased until its physical call has drained.
|
|
431
|
+
+ try:
|
|
432
|
+
+ await asyncio.shield(physical_generation)
|
|
433
|
+
+ except Exception:
|
|
434
|
+
+ logger.exception(
|
|
435
|
+
+ "Chatterbox Turbo generation failed while draining cancellation"
|
|
436
|
+
+ )
|
|
437
|
+
+ raise
|
|
438
|
+
+ finally:
|
|
439
|
+
+ self._active_replica_ids.discard(replica_id)
|
|
440
|
+
+ if self._unload_requested and not self._active_replica_ids:
|
|
441
|
+
+ self._unload_replicas()
|
|
442
|
+
+ elif self._available_replicas is available:
|
|
443
|
+
+ available.put_nowait(replica)
|
|
444
|
+
diff --git a/backend/models.py b/backend/models.py
|
|
445
|
+
index 7970ce4..2efe60e 100644
|
|
446
|
+
--- a/backend/models.py
|
|
447
|
+
+++ b/backend/models.py
|
|
448
|
+
@@ -445,6 +445,7 @@ class HealthResponse(BaseModel):
|
|
449
|
+
backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm)
|
|
450
|
+
supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable
|
|
451
|
+
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
|
|
452
|
+
+ generation_capacity: Optional[dict] = None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class DirectoryCheck(BaseModel):
|
|
456
|
+
diff --git a/backend/routes/generations.py b/backend/routes/generations.py
|
|
457
|
+
index 215c96c..f527065 100644
|
|
458
|
+
--- a/backend/routes/generations.py
|
|
459
|
+
+++ b/backend/routes/generations.py
|
|
460
|
+
@@ -13,7 +13,13 @@ from .. import config, models
|
|
461
|
+
from ..services import history, personality, profiles, tts
|
|
462
|
+
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
|
463
|
+
from ..services.generation import run_generation
|
|
464
|
+
-from ..services.task_queue import cancel_generation as cancel_generation_job, enqueue_generation
|
|
465
|
+
+from ..services.task_queue import (
|
|
466
|
+
+ GenerationQueueFull,
|
|
467
|
+
+ GenerationWorkersUnavailable,
|
|
468
|
+
+ assert_generation_workers_available,
|
|
469
|
+
+ cancel_generation as cancel_generation_job,
|
|
470
|
+
+ enqueue_generation,
|
|
471
|
+
+)
|
|
472
|
+
from ..utils.audio import load_audio
|
|
473
|
+
from ..utils.tasks import get_task_manager
|
|
474
|
+
|
|
475
|
+
@@ -53,6 +59,36 @@ def _resolve_generation_engine(data: models.GenerationRequest, profile) -> str:
|
|
476
|
+
return data.engine or getattr(profile, "default_engine", None) or getattr(profile, "preset_engine", None) or "qwen"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
+async def _enqueue_generation_or_reject(
|
|
480
|
+
+ *,
|
|
481
|
+
+ db,
|
|
482
|
+
+ task_manager,
|
|
483
|
+
+ generation_id,
|
|
484
|
+
+ generation_coro,
|
|
485
|
+
+ engine,
|
|
486
|
+
+):
|
|
487
|
+
+ try:
|
|
488
|
+
+ enqueue_generation(generation_id, generation_coro, engine=engine)
|
|
489
|
+
+ except GenerationQueueFull as error:
|
|
490
|
+
+ task_manager.complete_generation(generation_id)
|
|
491
|
+
+ await history.update_generation_status(
|
|
492
|
+
+ generation_id=generation_id,
|
|
493
|
+
+ status="failed",
|
|
494
|
+
+ db=db,
|
|
495
|
+
+ error=str(error),
|
|
496
|
+
+ )
|
|
497
|
+
+ raise HTTPException(status_code=429, detail=str(error)) from error
|
|
498
|
+
+ except GenerationWorkersUnavailable as error:
|
|
499
|
+
+ task_manager.complete_generation(generation_id)
|
|
500
|
+
+ await history.update_generation_status(
|
|
501
|
+
+ generation_id=generation_id,
|
|
502
|
+
+ status="failed",
|
|
503
|
+
+ db=db,
|
|
504
|
+
+ error=str(error),
|
|
505
|
+
+ )
|
|
506
|
+
+ raise HTTPException(status_code=503, detail=str(error)) from error
|
|
507
|
+
+
|
|
508
|
+
+
|
|
509
|
+
@router.post("/generate", response_model=models.GenerationResponse)
|
|
510
|
+
async def generate_speech(
|
|
511
|
+
data: models.GenerationRequest,
|
|
512
|
+
@@ -76,6 +112,11 @@ async def generate_speech(
|
|
513
|
+
|
|
514
|
+
model_size = (data.model_size or "1.7B") if engine_has_model_sizes(engine) else None
|
|
515
|
+
|
|
516
|
+
+ try:
|
|
517
|
+
+ assert_generation_workers_available()
|
|
518
|
+
+ except GenerationWorkersUnavailable as error:
|
|
519
|
+
+ raise HTTPException(status_code=503, detail=str(error)) from error
|
|
520
|
+
+
|
|
521
|
+
text = data.text
|
|
522
|
+
source = "manual"
|
|
523
|
+
if data.personality and getattr(profile, "personality", None):
|
|
524
|
+
@@ -123,9 +164,11 @@ async def generate_speech(
|
|
525
|
+
except Exception:
|
|
526
|
+
pass
|
|
527
|
+
|
|
528
|
+
- enqueue_generation(
|
|
529
|
+
- generation_id,
|
|
530
|
+
- run_generation(
|
|
531
|
+
+ await _enqueue_generation_or_reject(
|
|
532
|
+
+ db=db,
|
|
533
|
+
+ task_manager=task_manager,
|
|
534
|
+
+ generation_id=generation_id,
|
|
535
|
+
+ generation_coro=run_generation(
|
|
536
|
+
generation_id=generation_id,
|
|
537
|
+
profile_id=data.profile_id,
|
|
538
|
+
text=text,
|
|
539
|
+
@@ -139,7 +182,8 @@ async def generate_speech(
|
|
540
|
+
mode="generate",
|
|
541
|
+
max_chunk_chars=data.max_chunk_chars,
|
|
542
|
+
crossfade_ms=data.crossfade_ms,
|
|
543
|
+
- )
|
|
544
|
+
+ ),
|
|
545
|
+
+ engine=engine,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
return generation
|
|
549
|
+
@@ -169,9 +213,11 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
|
550
|
+
text=gen.text,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
- enqueue_generation(
|
|
554
|
+
- generation_id,
|
|
555
|
+
- run_generation(
|
|
556
|
+
+ await _enqueue_generation_or_reject(
|
|
557
|
+
+ db=db,
|
|
558
|
+
+ task_manager=task_manager,
|
|
559
|
+
+ generation_id=generation_id,
|
|
560
|
+
+ generation_coro=run_generation(
|
|
561
|
+
generation_id=generation_id,
|
|
562
|
+
profile_id=gen.profile_id,
|
|
563
|
+
text=gen.text,
|
|
564
|
+
@@ -181,7 +227,8 @@ async def retry_generation(generation_id: str, db: Session = Depends(get_db)):
|
|
565
|
+
seed=gen.seed,
|
|
566
|
+
instruct=gen.instruct,
|
|
567
|
+
mode="retry",
|
|
568
|
+
- )
|
|
569
|
+
+ ),
|
|
570
|
+
+ engine=gen.engine or "qwen",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
return models.GenerationResponse.model_validate(gen)
|
|
574
|
+
@@ -213,9 +260,11 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
|
575
|
+
|
|
576
|
+
version_id = str(uuid.uuid4())
|
|
577
|
+
|
|
578
|
+
- enqueue_generation(
|
|
579
|
+
- generation_id,
|
|
580
|
+
- run_generation(
|
|
581
|
+
+ await _enqueue_generation_or_reject(
|
|
582
|
+
+ db=db,
|
|
583
|
+
+ task_manager=task_manager,
|
|
584
|
+
+ generation_id=generation_id,
|
|
585
|
+
+ generation_coro=run_generation(
|
|
586
|
+
generation_id=generation_id,
|
|
587
|
+
profile_id=gen.profile_id,
|
|
588
|
+
text=gen.text,
|
|
589
|
+
@@ -226,7 +275,8 @@ async def regenerate_generation(generation_id: str, db: Session = Depends(get_db
|
|
590
|
+
instruct=gen.instruct,
|
|
591
|
+
mode="regenerate",
|
|
592
|
+
version_id=version_id,
|
|
593
|
+
- )
|
|
594
|
+
+ ),
|
|
595
|
+
+ engine=gen.engine or "qwen",
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
return models.GenerationResponse.model_validate(gen)
|
|
599
|
+
diff --git a/backend/routes/health.py b/backend/routes/health.py
|
|
600
|
+
index 1568455..7eee0ae 100644
|
|
601
|
+
--- a/backend/routes/health.py
|
|
602
|
+
+++ b/backend/routes/health.py
|
|
603
|
+
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|
604
|
+
|
|
605
|
+
from .. import config, models
|
|
606
|
+
from ..services import tts
|
|
607
|
+
+from ..services import task_queue
|
|
608
|
+
from ..database import get_db
|
|
609
|
+
from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows
|
|
610
|
+
|
|
611
|
+
@@ -176,6 +177,16 @@ async def health():
|
|
612
|
+
elif has_xpu:
|
|
613
|
+
default_variant = "xpu"
|
|
614
|
+
|
|
615
|
+
+ turbo_backend = None
|
|
616
|
+
+ try:
|
|
617
|
+
+ from ..backends import get_tts_backend_for_engine
|
|
618
|
+
+
|
|
619
|
+
+ turbo_backend = get_tts_backend_for_engine("chatterbox_turbo")
|
|
620
|
+
+ except Exception:
|
|
621
|
+
+ # Health remains available during optional backend import failures,
|
|
622
|
+
+ # but reports zero loaded replicas so clients fail closed to one lane.
|
|
623
|
+
+ turbo_backend = None
|
|
624
|
+
+
|
|
625
|
+
return models.HealthResponse(
|
|
626
|
+
status="healthy",
|
|
627
|
+
model_loaded=model_loaded,
|
|
628
|
+
@@ -188,6 +199,7 @@ async def health():
|
|
629
|
+
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", default_variant),
|
|
630
|
+
supports_rocm=is_amd_gpu_windows(),
|
|
631
|
+
gpu_compatibility_warning=gpu_compat_warning,
|
|
632
|
+
+ generation_capacity=task_queue.generation_capacity_snapshot(turbo_backend),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
diff --git a/backend/services/task_queue.py b/backend/services/task_queue.py
|
|
637
|
+
index 3ec4237..9a4202c 100644
|
|
638
|
+
--- a/backend/services/task_queue.py
|
|
639
|
+
+++ b/backend/services/task_queue.py
|
|
640
|
+
@@ -1,9 +1,8 @@
|
|
641
|
+
-"""
|
|
642
|
+
-Serial generation queue — ensures only one TTS inference runs at a time
|
|
643
|
+
-to avoid GPU contention.
|
|
644
|
+
-"""
|
|
645
|
+
+"""Bounded, engine-aware generation worker pool."""
|
|
646
|
+
|
|
647
|
+
import asyncio
|
|
648
|
+
+import logging
|
|
649
|
+
+import os
|
|
650
|
+
import traceback
|
|
651
|
+
from dataclasses import dataclass
|
|
652
|
+
from typing import Coroutine, Literal
|
|
653
|
+
@@ -18,14 +17,160 @@ class GenerationJob:
|
|
654
|
+
|
|
655
|
+
generation_id: str
|
|
656
|
+
coro: Coroutine
|
|
657
|
+
+ engine: str
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
-# Generation queue — serializes TTS inference to avoid GPU contention
|
|
661
|
+
+# Generation queue — bounded worker pool. Backend-specific capacity controls
|
|
662
|
+
+# whether jobs for one engine may execute physically in parallel.
|
|
663
|
+
_generation_queue: asyncio.Queue = None # type: ignore # initialized at startup
|
|
664
|
+
-_generation_worker_task: asyncio.Task | None = None
|
|
665
|
+
+_generation_worker_tasks: set[asyncio.Task] = set()
|
|
666
|
+
_queued_generation_ids: set[str] = set()
|
|
667
|
+
_running_generation_tasks: dict[str, asyncio.Task] = {}
|
|
668
|
+
+_running_generation_engines: dict[str, str] = {}
|
|
669
|
+
_cancelled_generation_ids: set[str] = set()
|
|
670
|
+
+_backend_entered_generation_ids: set[str] = set()
|
|
671
|
+
+_engine_generation_semaphores: dict[str, asyncio.Semaphore] = {}
|
|
672
|
+
+_generation_worker_epoch = 0
|
|
673
|
+
+_generation_worker_failures = 0
|
|
674
|
+
+_generation_worker_restarts = 0
|
|
675
|
+
+_generation_worker_pool_stopping = True
|
|
676
|
+
+
|
|
677
|
+
+logger = logging.getLogger(__name__)
|
|
678
|
+
+
|
|
679
|
+
+
|
|
680
|
+
+class GenerationQueueFull(RuntimeError):
|
|
681
|
+
+ """Raised before admission when the bounded generation queue is full."""
|
|
682
|
+
+
|
|
683
|
+
+
|
|
684
|
+
+class GenerationWorkersUnavailable(RuntimeError):
|
|
685
|
+
+ """Raised before admission when no live worker can own a generation."""
|
|
686
|
+
+
|
|
687
|
+
+
|
|
688
|
+
+def generation_queue_capacity() -> int:
|
|
689
|
+
+ return _bounded_int_env("VOICEBOX_MAX_PENDING_GENERATIONS", 64, 1, 512)
|
|
690
|
+
+
|
|
691
|
+
+
|
|
692
|
+
+def _bounded_int_env(name: str, fallback: int, minimum: int, maximum: int) -> int:
|
|
693
|
+
+ try:
|
|
694
|
+
+ configured = int(os.environ.get(name, str(fallback)) or fallback)
|
|
695
|
+
+ except (TypeError, ValueError):
|
|
696
|
+
+ configured = fallback
|
|
697
|
+
+ return min(maximum, max(minimum, configured))
|
|
698
|
+
+
|
|
699
|
+
+
|
|
700
|
+
+def generation_worker_count() -> int:
|
|
701
|
+
+ return _bounded_int_env("VOICEBOX_GENERATION_WORKERS", 2, 1, 8)
|
|
702
|
+
+
|
|
703
|
+
+
|
|
704
|
+
+def _live_generation_workers() -> list[asyncio.Task]:
|
|
705
|
+
+ return [task for task in _generation_worker_tasks if not task.done()]
|
|
706
|
+
+
|
|
707
|
+
+
|
|
708
|
+
+def generation_workers_ready() -> bool:
|
|
709
|
+
+ return (
|
|
710
|
+
+ not _generation_worker_pool_stopping
|
|
711
|
+
+ and _generation_queue is not None
|
|
712
|
+
+ and len(_live_generation_workers()) > 0
|
|
713
|
+
+ )
|
|
714
|
+
+
|
|
715
|
+
+
|
|
716
|
+
+def assert_generation_workers_available() -> int:
|
|
717
|
+
+ """Restore supervised capacity, then fail before persistence if none exists."""
|
|
718
|
+
+ _ensure_generation_workers(restarted=True)
|
|
719
|
+
+ live = len(_live_generation_workers())
|
|
720
|
+
+ if not generation_workers_ready():
|
|
721
|
+
+ raise GenerationWorkersUnavailable(
|
|
722
|
+
+ f"Voicebox generation workers are unavailable "
|
|
723
|
+
+ f"({live}/{generation_worker_count()} live)"
|
|
724
|
+
+ )
|
|
725
|
+
+ return live
|
|
726
|
+
+
|
|
727
|
+
+
|
|
728
|
+
+def engine_generation_capacity(engine: str) -> int:
|
|
729
|
+
+ normalized = str(engine or "unknown").strip().lower()
|
|
730
|
+
+ if normalized == "chatterbox_turbo":
|
|
731
|
+
+ return _bounded_int_env(
|
|
732
|
+
+ "VOICEBOX_CHATTERBOX_TURBO_REPLICAS",
|
|
733
|
+
+ 2,
|
|
734
|
+
+ 1,
|
|
735
|
+
+ 4,
|
|
736
|
+
+ )
|
|
737
|
+
+ # Other upstream backends still expose singleton model objects. They may
|
|
738
|
+
+ # run alongside another engine, but same-engine overlap remains closed
|
|
739
|
+
+ # until that backend implements independent replicas of its own.
|
|
740
|
+
+ return 1
|
|
741
|
+
+
|
|
742
|
+
+
|
|
743
|
+
+def engine_supports_physical_task_cancellation(engine: str) -> bool:
|
|
744
|
+
+ """Whether cancellation is proven to retain all native load/generate leases."""
|
|
745
|
+
+ return str(engine or "").strip().lower() == "chatterbox_turbo"
|
|
746
|
+
+
|
|
747
|
+
+
|
|
748
|
+
+def generation_capacity_snapshot(turbo_backend=None) -> dict:
|
|
749
|
+
+ turbo_capacity = {
|
|
750
|
+
+ "configured": engine_generation_capacity("chatterbox_turbo"),
|
|
751
|
+
+ "loaded": 0,
|
|
752
|
+
+ "active": 0,
|
|
753
|
+
+ "available": 0,
|
|
754
|
+
+ }
|
|
755
|
+
+ capacity_reader = getattr(turbo_backend, "generation_capacity", None)
|
|
756
|
+
+ if callable(capacity_reader):
|
|
757
|
+
+ reported = capacity_reader()
|
|
758
|
+
+ if isinstance(reported, dict):
|
|
759
|
+
+ for key in turbo_capacity:
|
|
760
|
+
+ value = reported.get(key)
|
|
761
|
+
+ if isinstance(value, int) and value >= 0:
|
|
762
|
+
+ turbo_capacity[key] = value
|
|
763
|
+
+ live_workers = len(_live_generation_workers())
|
|
764
|
+
+ configured_workers = generation_worker_count()
|
|
765
|
+
+ return {
|
|
766
|
+
+ "workers": {
|
|
767
|
+
+ "configured": configured_workers,
|
|
768
|
+
+ "live": live_workers,
|
|
769
|
+
+ # Retain the old field for clients deployed before the liveness
|
|
770
|
+
+ # contract was expanded. It means live worker tasks, not busy jobs.
|
|
771
|
+
+ "active": live_workers,
|
|
772
|
+
+ "failed": _generation_worker_failures,
|
|
773
|
+
+ "restarts": _generation_worker_restarts,
|
|
774
|
+
+ "restarting": (
|
|
775
|
+
+ max(0, configured_workers - live_workers)
|
|
776
|
+
+ if not _generation_worker_pool_stopping
|
|
777
|
+
+ else 0
|
|
778
|
+
+ ),
|
|
779
|
+
+ "ready": generation_workers_ready(),
|
|
780
|
+
+ "stopping": _generation_worker_pool_stopping,
|
|
781
|
+
+ },
|
|
782
|
+
+ "queue": {
|
|
783
|
+
+ "queued": len(_queued_generation_ids),
|
|
784
|
+
+ "running": len(_running_generation_tasks),
|
|
785
|
+
+ "max_pending": generation_queue_capacity(),
|
|
786
|
+
+ },
|
|
787
|
+
+ "chatterbox_turbo": turbo_capacity,
|
|
788
|
+
+ }
|
|
789
|
+
+
|
|
790
|
+
+
|
|
791
|
+
+def _engine_semaphore(engine: str) -> asyncio.Semaphore:
|
|
792
|
+
+ normalized = str(engine or "unknown").strip().lower() or "unknown"
|
|
793
|
+
+ semaphore = _engine_generation_semaphores.get(normalized)
|
|
794
|
+
+ if semaphore is None:
|
|
795
|
+
+ semaphore = asyncio.Semaphore(engine_generation_capacity(normalized))
|
|
796
|
+
+ _engine_generation_semaphores[normalized] = semaphore
|
|
797
|
+
+ return semaphore
|
|
798
|
+
+
|
|
799
|
+
+
|
|
800
|
+
+async def _run_generation_job(job: GenerationJob) -> None:
|
|
801
|
+
+ entered_backend = False
|
|
802
|
+
+ try:
|
|
803
|
+
+ async with _engine_semaphore(job.engine):
|
|
804
|
+
+ entered_backend = True
|
|
805
|
+
+ _backend_entered_generation_ids.add(job.generation_id)
|
|
806
|
+
+ await job.coro
|
|
807
|
+
+ finally:
|
|
808
|
+
+ _backend_entered_generation_ids.discard(job.generation_id)
|
|
809
|
+
+ # A coroutine object that was cancelled while waiting for engine
|
|
810
|
+
+ # capacity was never awaited and must be explicitly closed.
|
|
811
|
+
+ if not entered_backend:
|
|
812
|
+
+ job.coro.close()
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def create_background_task(coro) -> asyncio.Task:
|
|
816
|
+
@@ -46,12 +191,22 @@ async def _generation_worker():
|
|
817
|
+
job.coro.close()
|
|
818
|
+
continue
|
|
819
|
+
|
|
820
|
+
- task = asyncio.create_task(job.coro)
|
|
821
|
+
+ task = asyncio.create_task(_run_generation_job(job))
|
|
822
|
+
_running_generation_tasks[job.generation_id] = task
|
|
823
|
+
+ _running_generation_engines[job.generation_id] = str(
|
|
824
|
+
+ job.engine or "unknown"
|
|
825
|
+
+ ).strip().lower()
|
|
826
|
+
_queued_generation_ids.discard(job.generation_id)
|
|
827
|
+
try:
|
|
828
|
+
await task
|
|
829
|
+
except asyncio.CancelledError:
|
|
830
|
+
+ # Backends that dispatch worker threads drain those workers
|
|
831
|
+
+ # before allowing this task to reach its cancelled state.
|
|
832
|
+
+ # Preserve cancellation of the worker itself during pool
|
|
833
|
+
+ # shutdown; suppress only cancellation of the child job.
|
|
834
|
+
+ worker = asyncio.current_task()
|
|
835
|
+
+ if worker is not None and worker.cancelling():
|
|
836
|
+
+ raise
|
|
837
|
+
if not task.cancelled():
|
|
838
|
+
raise
|
|
839
|
+
except Exception:
|
|
840
|
+
@@ -62,10 +217,63 @@ async def _generation_worker():
|
|
841
|
+
)
|
|
842
|
+
finally:
|
|
843
|
+
_running_generation_tasks.pop(job.generation_id, None)
|
|
844
|
+
+ _running_generation_engines.pop(job.generation_id, None)
|
|
845
|
+
_queued_generation_ids.discard(job.generation_id)
|
|
846
|
+
+ _cancelled_generation_ids.discard(job.generation_id)
|
|
847
|
+
_generation_queue.task_done()
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
+def _generation_worker_finished(task: asyncio.Task, epoch: int) -> None:
|
|
851
|
+
+ """Replace a worker that exits outside an intentional pool generation."""
|
|
852
|
+
+ global _generation_worker_failures
|
|
853
|
+
+
|
|
854
|
+
+ _generation_worker_tasks.discard(task)
|
|
855
|
+
+ if epoch != _generation_worker_epoch or _generation_worker_pool_stopping:
|
|
856
|
+
+ return
|
|
857
|
+
+ _generation_worker_failures += 1
|
|
858
|
+
+ if task.cancelled():
|
|
859
|
+
+ logger.error("Voicebox generation worker was cancelled unexpectedly")
|
|
860
|
+
+ else:
|
|
861
|
+
+ error = task.exception()
|
|
862
|
+
+ if error is None:
|
|
863
|
+
+ logger.error("Voicebox generation worker exited unexpectedly")
|
|
864
|
+
+ else:
|
|
865
|
+
+ logger.exception(
|
|
866
|
+
+ "Voicebox generation worker crashed",
|
|
867
|
+
+ exc_info=(type(error), error, error.__traceback__),
|
|
868
|
+
+ )
|
|
869
|
+
+ try:
|
|
870
|
+
+ loop = asyncio.get_running_loop()
|
|
871
|
+
+ except RuntimeError:
|
|
872
|
+
+ return
|
|
873
|
+
+ if loop.is_running():
|
|
874
|
+
+ loop.call_soon(_ensure_generation_workers, True)
|
|
875
|
+
+
|
|
876
|
+
+
|
|
877
|
+
+def _start_generation_worker(*, restarted: bool) -> None:
|
|
878
|
+
+ global _generation_worker_restarts
|
|
879
|
+
+
|
|
880
|
+
+ epoch = _generation_worker_epoch
|
|
881
|
+
+ worker = create_background_task(_generation_worker())
|
|
882
|
+
+ _generation_worker_tasks.add(worker)
|
|
883
|
+
+ worker.add_done_callback(
|
|
884
|
+
+ lambda completed, worker_epoch=epoch: _generation_worker_finished(
|
|
885
|
+
+ completed,
|
|
886
|
+
+ worker_epoch,
|
|
887
|
+
+ )
|
|
888
|
+
+ )
|
|
889
|
+
+ if restarted:
|
|
890
|
+
+ _generation_worker_restarts += 1
|
|
891
|
+
+
|
|
892
|
+
+
|
|
893
|
+
+def _ensure_generation_workers(restarted: bool = False) -> None:
|
|
894
|
+
+ if _generation_worker_pool_stopping or _generation_queue is None:
|
|
895
|
+
+ return
|
|
896
|
+
+ missing = generation_worker_count() - len(_live_generation_workers())
|
|
897
|
+
+ for _ in range(max(0, missing)):
|
|
898
|
+
+ _start_generation_worker(restarted=restarted)
|
|
899
|
+
+
|
|
900
|
+
+
|
|
901
|
+
async def _force_fail_if_active(generation_id: str, error: str) -> None:
|
|
902
|
+
"""Best-effort recovery — flip an active row to failed if the worker
|
|
903
|
+
bailed before writing a terminal status. Catches the case where the gen
|
|
904
|
+
@@ -93,20 +301,47 @@ async def _force_fail_if_active(generation_id: str, error: str) -> None:
|
|
905
|
+
traceback.print_exc()
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
-def enqueue_generation(generation_id: str, coro):
|
|
909
|
+
- """Add a generation coroutine to the serial queue."""
|
|
910
|
+
- if _generation_queue is None:
|
|
911
|
+
- raise RuntimeError("Generation queue has not been initialized")
|
|
912
|
+
+def enqueue_generation(generation_id: str, coro, engine: str = "unknown"):
|
|
913
|
+
+ """Add generation work to the bounded, engine-aware worker pool."""
|
|
914
|
+
+ try:
|
|
915
|
+
+ assert_generation_workers_available()
|
|
916
|
+
+ except GenerationWorkersUnavailable:
|
|
917
|
+
+ coro.close()
|
|
918
|
+
+ raise
|
|
919
|
+
|
|
920
|
+
+ job = GenerationJob(
|
|
921
|
+
+ generation_id=generation_id,
|
|
922
|
+
+ coro=coro,
|
|
923
|
+
+ engine=str(engine or "unknown"),
|
|
924
|
+
+ )
|
|
925
|
+
+ try:
|
|
926
|
+
+ _generation_queue.put_nowait(job)
|
|
927
|
+
+ except asyncio.QueueFull as error:
|
|
928
|
+
+ # The coroutine was constructed by the route before admission. Close
|
|
929
|
+
+ # it explicitly so overload cannot leak an un-awaited coroutine.
|
|
930
|
+
+ job.coro.close()
|
|
931
|
+
+ raise GenerationQueueFull(
|
|
932
|
+
+ f"Voicebox generation queue is full ({generation_queue_capacity()} waiting)"
|
|
933
|
+
+ ) from error
|
|
934
|
+
_queued_generation_ids.add(generation_id)
|
|
935
|
+
- _generation_queue.put_nowait(GenerationJob(generation_id=generation_id, coro=coro))
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def cancel_generation(generation_id: str) -> Literal["queued", "running"] | None:
|
|
939
|
+
"""Cancel a queued or running generation if it is still active."""
|
|
940
|
+
running_task = _running_generation_tasks.get(generation_id)
|
|
941
|
+
if running_task is not None:
|
|
942
|
+
- running_task.cancel()
|
|
943
|
+
+ entered_backend = generation_id in _backend_entered_generation_ids
|
|
944
|
+
+ engine = _running_generation_engines.get(generation_id, "unknown")
|
|
945
|
+
+ if not entered_backend:
|
|
946
|
+
+ _cancelled_generation_ids.add(generation_id)
|
|
947
|
+
+ running_task.cancel()
|
|
948
|
+
+ return "queued"
|
|
949
|
+
+ if engine_supports_physical_task_cancellation(engine):
|
|
950
|
+
+ _cancelled_generation_ids.add(generation_id)
|
|
951
|
+
+ running_task.cancel()
|
|
952
|
+
+ # Unsafe singleton backends intentionally keep running to terminal
|
|
953
|
+
+ # completion. Their semaphore remains physically leased, so a cancel
|
|
954
|
+
+ # request can never overlap an uninterruptible asyncio.to_thread call.
|
|
955
|
+
return "running"
|
|
956
|
+
|
|
957
|
+
if generation_id in _queued_generation_ids:
|
|
958
|
+
@@ -122,18 +357,48 @@ def init_queue(force: bool = False):
|
|
959
|
+
|
|
960
|
+
Must be called once during application startup (inside a running event loop).
|
|
961
|
+
"""
|
|
962
|
+
- global _generation_queue, _generation_worker_task
|
|
963
|
+
- global _queued_generation_ids, _running_generation_tasks, _cancelled_generation_ids
|
|
964
|
+
+ global _generation_queue, _generation_worker_tasks
|
|
965
|
+
+ global _queued_generation_ids, _running_generation_tasks, _running_generation_engines
|
|
966
|
+
+ global _cancelled_generation_ids
|
|
967
|
+
+ global _backend_entered_generation_ids, _engine_generation_semaphores
|
|
968
|
+
+ global _generation_worker_epoch, _generation_worker_pool_stopping
|
|
969
|
+
|
|
970
|
+
- if _generation_worker_task is not None and not _generation_worker_task.done():
|
|
971
|
+
+ live_workers = _live_generation_workers()
|
|
972
|
+
+ if live_workers:
|
|
973
|
+
if not force:
|
|
974
|
+
+ _generation_worker_pool_stopping = False
|
|
975
|
+
+ _ensure_generation_workers()
|
|
976
|
+
return
|
|
977
|
+
- _generation_worker_task.cancel()
|
|
978
|
+
- for task in list(_running_generation_tasks.values()):
|
|
979
|
+
- task.cancel()
|
|
980
|
+
+ if any(not task.done() for task in _running_generation_tasks.values()):
|
|
981
|
+
+ raise RuntimeError(
|
|
982
|
+
+ "Cannot reinitialize generation workers while physical jobs are active"
|
|
983
|
+
+ )
|
|
984
|
+
+ _generation_worker_pool_stopping = True
|
|
985
|
+
+ _generation_worker_epoch += 1
|
|
986
|
+
+ for worker in live_workers:
|
|
987
|
+
+ worker.cancel()
|
|
988
|
+
|
|
989
|
+
- _generation_queue = asyncio.Queue()
|
|
990
|
+
+ _generation_queue = asyncio.Queue(maxsize=generation_queue_capacity())
|
|
991
|
+
_queued_generation_ids = set()
|
|
992
|
+
_running_generation_tasks = {}
|
|
993
|
+
+ _running_generation_engines = {}
|
|
994
|
+
_cancelled_generation_ids = set()
|
|
995
|
+
- _generation_worker_task = create_background_task(_generation_worker())
|
|
996
|
+
+ _backend_entered_generation_ids = set()
|
|
997
|
+
+ _engine_generation_semaphores = {}
|
|
998
|
+
+ _generation_worker_tasks = set()
|
|
999
|
+
+ _generation_worker_pool_stopping = False
|
|
1000
|
+
+ _ensure_generation_workers()
|
|
1001
|
+
+
|
|
1002
|
+
+
|
|
1003
|
+
+async def shutdown_queue() -> None:
|
|
1004
|
+
+ """Stop workers intentionally without allowing the supervisor to replace them."""
|
|
1005
|
+
+ global _generation_worker_epoch, _generation_worker_pool_stopping
|
|
1006
|
+
+
|
|
1007
|
+
+ _generation_worker_pool_stopping = True
|
|
1008
|
+
+ _generation_worker_epoch += 1
|
|
1009
|
+
+ workers = list(_generation_worker_tasks)
|
|
1010
|
+
+ for worker in workers:
|
|
1011
|
+
+ worker.cancel()
|
|
1012
|
+
+ if workers:
|
|
1013
|
+
+ await asyncio.gather(*workers, return_exceptions=True)
|
|
1014
|
+
+ _generation_worker_tasks.clear()
|