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.
@@ -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()