fix(macos): isolate OmniVoice MPS generation

This commit is contained in:
Palash Debnath
2026-08-28 19:21:06 +05:30
parent de51120d6a
commit 3b6e15dad5
13 changed files with 403 additions and 57 deletions
+1
View File
@@ -10,6 +10,7 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- OmniVoice generation on Apple Silicon now runs in a crash-isolated child, so fatal MPS memory exits no longer take down the local backend (#1697, #1698) — thanks @ndntran14!
- Model-load GPU exhaustion now returns a sanitized, actionable dubbing error, and readiness correctly attributes the shared model status to TTS (#1695)
- Source-mode development now restarts an isolated backend crash without tearing down the UI, while repeated crash loops still stop loudly with diagnostics (#1690)
- Dubbing playback now keeps an audible companion source when a WebView can render the preview picture but cannot decode its audio (#1692)
+13 -6
View File
@@ -361,7 +361,7 @@ LONGFORM_NUM_STEP = 32
LONGFORM_GUIDANCE_SCALE = 2.0
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> None:
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> int | None:
"""Apply a profile's pinned seed to this synth call (#1139).
``_resolve_voice`` has always fetched the profile ``seed`` but only the
@@ -380,11 +380,13 @@ def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> None:
must cover /generate and here together, not one path.
"""
if base_seed is None:
return
return None
import torch
from services.audiobook import segment_seed
torch.manual_seed(segment_seed(base_seed, text, nonce))
seed = segment_seed(base_seed, text, nonce)
torch.manual_seed(seed)
return seed
def _base_seed(opts: ExpressiveOptions, voice: dict):
@@ -508,16 +510,21 @@ def _build_synth(
"get_model": get_model, "language": language, "opts": opts}
backend = cls()
extra = _generic_extra_kwargs(opts)
native_proxy = bool(getattr(cls, "supports_native_omnivoice_controls", False))
extra = (_omnivoice_sampling_kwargs(opts) if native_proxy
else _generic_extra_kwargs(opts))
next_nonce = _make_occ_counter(opts)
def synth(text, voice_id, speed=None):
v = resolve(voice_id)
_seed_segment_rng(_base_seed(opts, v), text, next_nonce())
seed = _seed_segment_rng(_base_seed(opts, v), text, next_nonce())
call_extra = dict(extra)
if native_proxy and seed is not None:
call_extra["seed"] = seed
return backend.generate(
text, language=language, ref_audio=v["ref_audio"],
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
speed=float(speed) if speed else 1.0, **extra,
speed=float(speed) if speed else 1.0, **call_extra,
)
return {"mode": "generic", "resolve": resolve, "engine_id": engine_id,
"synth": synth, "sample_rate": backend.sample_rate}
+56 -9
View File
@@ -813,15 +813,17 @@ def _run_backend_inference(
backend, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, denoise, postprocess_output,
used_seed, effect_preset="broadcast",
max_chunk_chars=None, crossfade_ms=None, *, dropped_sink=None,
max_chunk_chars=None, crossfade_ms=None, *, t_shift=None,
layer_penalty_factor=None, position_temperature=None,
class_temperature=None, dropped_sink=None,
):
"""Engine-aware twin of :func:`_run_inference` (issue #312).
Runs the request through a pluggable ``TTSBackend`` adapter instead of the
VoiceStudio model directly. The adapter protocol is narrower than the
VoiceStudio-native surface engine-specific extras (``t_shift``,
``layer_penalty_factor``, ) only exist on the native path, which is why
VoiceStudio itself still goes through ``_run_inference``.
VoiceStudio model directly. A crash-isolated OmniVoice proxy advertises
``supports_native_omnivoice_controls`` and receives the same advanced
controls and per-call seed as the native path; other adapters keep the
narrower protocol unchanged.
"""
import torch
try:
@@ -836,6 +838,18 @@ def _run_backend_inference(
instruct=instruct, num_step=num_step, guidance_scale=guidance_scale,
speed=speed, denoise=denoise, postprocess_output=postprocess_output,
)
native_proxy = bool(
getattr(backend, "supports_native_omnivoice_controls", False)
)
if native_proxy:
gen_kwargs.update({
key: value for key, value in {
"t_shift": t_shift,
"layer_penalty_factor": layer_penalty_factor,
"position_temperature": position_temperature,
"class_temperature": class_temperature,
}.items() if value is not None
})
sr = backend.sample_rate
# Inline [pause Nms] markers (issue #276) work for every engine — the
@@ -845,10 +859,17 @@ def _run_backend_inference(
has_pause = len(segments) > 1 or (segments and segments[0][1] > 0)
if has_pause:
first_span = True
def _gen_span(span_text):
nonlocal first_span
# Per-span duration is left to the engine; an explicit overall
# `duration` can't be meaningfully split across spans.
return backend.generate(span_text, duration=None, **gen_kwargs)
span_kwargs = dict(gen_kwargs)
if native_proxy and first_span and used_seed is not None:
span_kwargs["seed"] = used_seed
first_span = False
return backend.generate(span_text, duration=None, **span_kwargs)
audio_out = _render_with_pauses(_gen_span, segments, sr)
else:
# Wave 1.2: sentence-boundary chunking for long text (see
@@ -865,12 +886,19 @@ def _run_backend_inference(
for i, chunk_text in enumerate(text_chunks):
if used_seed is not None:
torch.manual_seed(used_seed + i)
parts.append(backend.generate(chunk_text, duration=None, **gen_kwargs))
chunk_kwargs = dict(gen_kwargs)
if native_proxy and used_seed is not None:
chunk_kwargs["seed"] = used_seed + i
parts.append(backend.generate(
chunk_text, duration=None, **chunk_kwargs
))
_note_generate_progress()
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms,
texts=text_chunks,
sink=dropped_sink)
else:
if native_proxy and used_seed is not None:
gen_kwargs["seed"] = used_seed
audio_out = backend.generate(text, duration=duration, **gen_kwargs)
return _apply_effect_chain(
@@ -1853,6 +1881,17 @@ async def generate_speech(
instruct=instruct, num_step=num_step,
guidance_scale=guidance_scale, speed=speed,
denoise=denoise, postprocess_output=postprocess_output,
**({
key: value for key, value in {
"t_shift": t_shift,
"layer_penalty_factor": layer_penalty_factor,
"position_temperature": position_temperature,
"class_temperature": class_temperature,
"seed": used_seed + i if used_seed is not None else None,
}.items() if value is not None
} if getattr(
_backend, "supports_native_omnivoice_controls", False
) else {}),
)
sr = _backend.sample_rate
skip = getattr(_backend, "applies_own_mastering", False)
@@ -1924,7 +1963,11 @@ async def generate_speech(
_backend, text, language, ref_audio_path, ref_text,
instruct, duration, num_step, guidance_scale, speed,
denoise, postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_sink,
max_chunk_chars, crossfade_ms, t_shift=t_shift,
layer_penalty_factor=layer_penalty_factor,
position_temperature=position_temperature,
class_temperature=class_temperature,
dropped_sink=_dropped_sink,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
@@ -2127,7 +2170,11 @@ async def generate_speech(
_backend, text, language, ref_audio_path, ref_text, instruct,
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
max_chunk_chars, crossfade_ms, t_shift=t_shift,
layer_penalty_factor=layer_penalty_factor,
position_temperature=position_temperature,
class_temperature=class_temperature,
dropped_sink=_dropped_text,
)
else:
_local_render = functools.partial(
+21 -11
View File
@@ -39,6 +39,26 @@ CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))
_perf_counter = time.perf_counter
async def _resolve_stream_backend(engine_id: str | None):
"""Resolve the live-stream engine without bypassing host isolation."""
from services.tts_backend import (
OmniVoiceBackend,
active_backend_id,
get_active_tts_backend,
get_backend_class,
)
if engine_id:
return get_backend_class(engine_id)()
cls = get_backend_class(active_backend_id())
if cls is OmniVoiceBackend:
from services.model_manager import get_model
return get_active_tts_backend(model=await get_model())
return get_active_tts_backend()
class StreamTTSRequest(BaseModel):
"""Client request for streaming TTS."""
text: str
@@ -132,10 +152,6 @@ async def ws_tts(websocket: WebSocket):
try:
# Resolve engine
from services.tts_backend import (
get_active_tts_backend,
get_backend_class,
)
engine_id = data.get("engine")
# #1224: leave a breadcrumb when memory is already tight before
# a heavy load. /generate has done this since the 16 GB-Mac
@@ -151,13 +167,7 @@ async def ws_tts(websocket: WebSocket):
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
except Exception:
pass
if engine_id:
cls = get_backend_class(engine_id)
backend = cls()
else:
from services.model_manager import get_model
model = await get_model()
backend = get_active_tts_backend(model=model)
backend = await _resolve_stream_backend(engine_id)
# ── Routing gate (#21 — no silent CPU fallback). WebSockets have
# no response headers, so this uses frames: an error frame +
@@ -1,7 +1,9 @@
"""omnivoice-subprocess: the resident OmniVoice TTS engine in a crash-isolated
sidecar process (#730/#1190).
The default ``omnivoice`` engine runs in-process on the GPU ``ThreadPoolExecutor``.
The ``omnivoice`` engine runs in-process on CUDA, ROCm, and CPU. On MPS it is
resolved to :class:`OmniVoiceMPSSubprocessBackend` so a fatal native allocator
exit cannot take down the local API process.
When a generate or load there exceeds its execution budget the pool is "reset"
but the abandoned worker *thread* cannot be killed (Python cannot interrupt a
native torch/MPS call), so it holds the MPS device until it finishes on its
@@ -13,16 +15,11 @@ timeout the parent's watchdog calls ``proc.kill()``, reclaiming the child's
VRAM/device, and the next request transparently respawns a fresh sidecar. That
is the one thing the in-process engine structurally cannot do.
OPT-IN (Settings -> Engines, or ``OMNIVOICE_TTS_BACKEND=omnivoice-subprocess``);
the in-process ``omnivoice`` stays the default so existing users see no change.
The explicit ``omnivoice-subprocess`` id remains available on every host for
operators who want the same containment elsewhere.
Tradeoff vs the in-process engine: identical model and quality, a little extra
per-call overhead (one stdio round-trip), and it does not carry the native
advanced-parameter surface (``t_shift`` / ``layer_penalty_factor`` /
``position_temperature`` / ``class_temperature``) or parent-side seed
determinism, because the generic ``backend.generate`` path does not forward
those. Acceptable for unattended / reaction-triggered use where reliability
matters more than those controls.
Tradeoff vs the in-process engine: identical model, controls, seed behavior,
and quality, with a little extra per-call overhead (one stdio round-trip).
Unlike IndexTTS / dots.tts / Supertonic-3, this sidecar runs under the PARENT
interpreter (``venv_python() -> sys.executable``): the goal here is crash
@@ -102,4 +99,34 @@ class OmniVoiceSubprocessBackend(SubprocessBackend):
return ["multi"]
__all__ = ["OmniVoiceSubprocessBackend"]
class OmniVoiceMPSSubprocessBackend(OmniVoiceSubprocessBackend):
"""Effective ``omnivoice`` implementation on MPS.
Native torch/MPS allocator failures can terminate the process without a
catchable Python exception. Keeping the same engine id and model surface in
a child makes that failure recoverable while Settings, APIs, and saved
projects continue to refer to ``omnivoice``.
"""
id = "omnivoice"
display_name = "VoiceStudio (k2-fsa/OmniVoice, 600+ languages)"
supports_native_omnivoice_controls = True
def generate(self, text: str, **kw):
from services.model_manager import make_room_before_generate
make_room_before_generate()
try:
return super().generate(text, **kw)
except RuntimeError as exc:
if "sidecar closed pipe mid-generate" not in str(exc):
raise
raise RuntimeError(
"The isolated OmniVoice engine stopped during generation, "
"usually because macOS reclaimed it under memory pressure. "
"The VoiceStudio backend is still running. Close memory-heavy "
"apps or select a smaller TTS engine, then retry."
) from exc
__all__ = ["OmniVoiceMPSSubprocessBackend", "OmniVoiceSubprocessBackend"]
@@ -50,6 +50,8 @@ OMNIVOICE_SAMPLE_RATE = 24000
_GEN_KW_ALLOWLIST = (
"language", "instruct", "duration", "num_step", "guidance_scale",
"speed", "denoise", "postprocess_output", "preprocess_prompt",
"t_shift", "layer_penalty_factor", "position_temperature",
"class_temperature", "audio_chunk_duration", "audio_chunk_threshold",
)
_model = None
@@ -183,6 +185,12 @@ def _handle_synthesize(msg: dict, stdout) -> None:
ref_text = msg.get("ref_text") or None
gen_kw = {k: msg[k] for k in _GEN_KW_ALLOWLIST if k in msg}
seed = msg.get("seed")
if seed is not None:
import torch
torch.manual_seed(int(seed))
audios = model.generate(
text=text, ref_audio=ref_audio, ref_text=ref_text, **gen_kw
)
+15
View File
@@ -2849,6 +2849,21 @@ async def preload_model():
if model is not None:
return # already loaded
# On MPS the configured ``omnivoice`` id resolves to a crash-isolated
# sidecar. Warming the native singleton here would put the same fatal MPS
# allocator risk back into the API process before the isolated engine is
# ever asked to synthesize.
try:
from core.device_caps import detect_host_caps
if detect_host_caps().family == "mps":
logger.info(
"Native TTS preload skipped: OmniVoice uses crash isolation on this host."
)
return
except Exception: # noqa: BLE001 -- preload selection must stay best-effort
logger.debug("effective TTS preload selection failed", exc_info=True)
# A machine lending its GPU has no local user to warm the model FOR. This
# preload exists to make the first /generate feel instant for the person
# sitting in front of the app; on a headless node there is nobody sitting
+21 -1
View File
@@ -2398,6 +2398,7 @@ def list_backends() -> list[dict]:
out: list[dict] = []
for bid, cls in _REGISTRY.items():
cls = _effective_backend_class(bid, cls, caps.family)
try:
ok, msg = cls.is_available()
except Exception:
@@ -2471,10 +2472,29 @@ def list_backends() -> list[dict]:
return out
def _effective_backend_class(
backend_id: str,
backend_cls: type[TTSBackend],
host_family: str | None = None,
) -> type[TTSBackend]:
"""Resolve host-specific containment without changing the configured id."""
if backend_id != "omnivoice":
return backend_cls
if host_family is None:
from core.device_caps import detect_host_caps
host_family = detect_host_caps().family
if host_family != "mps":
return backend_cls
from engines.omnivoice_subprocess import OmniVoiceMPSSubprocessBackend
return OmniVoiceMPSSubprocessBackend
def get_backend_class(backend_id: str) -> type[TTSBackend]:
if backend_id not in _REGISTRY:
raise ValueError(f"Unknown TTS backend: {backend_id!r}. Known: {list(_REGISTRY)}")
return _REGISTRY[backend_id]
return _effective_backend_class(backend_id, _REGISTRY[backend_id])
def cloning_capable_engine_ids() -> list[str]:
+188 -2
View File
@@ -22,6 +22,7 @@ import os
import subprocess
import sys
import time
import asyncio
from pathlib import Path
import pytest
@@ -30,8 +31,11 @@ from services.subprocess_backend import (
RECV_TIMEOUT_S,
SubprocessBackend,
)
from services.tts_backend import get_backend_class
from engines.omnivoice_subprocess import OmniVoiceSubprocessBackend
from services.tts_backend import OmniVoiceBackend, get_backend_class, list_backends
from engines.omnivoice_subprocess import (
OmniVoiceMPSSubprocessBackend,
OmniVoiceSubprocessBackend,
)
# ── stub sidecar (model-free) ──────────────────────────────────────────────
@@ -69,6 +73,8 @@ while True:
sys.exit(0)
elif op == "synthesize":
t = m.get("text", "")
if t == "CRASH":
os._exit(137)
if t == "HANG":
while True: # wedge forever; the parent must hard-kill us
time.sleep(1)
@@ -116,6 +122,80 @@ def test_registry_resolves_to_subprocess_backend():
assert get_backend_class("omnivoice-subprocess") is OmniVoiceSubprocessBackend
@pytest.mark.parametrize(
("family", "expected_name"),
[("mps", "OmniVoiceMPSSubprocessBackend"), ("cuda", "OmniVoiceBackend"),
("cpu", "OmniVoiceBackend")],
)
def test_omnivoice_is_crash_isolated_only_on_mps(monkeypatch, family, expected_name):
from core.device_caps import HostCaps
available = (family, "cpu") if family != "cpu" else ("cpu",)
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family=family, available_families=available),
)
resolved = get_backend_class("omnivoice")
assert resolved.__name__ == expected_name
if family != "mps":
assert resolved is OmniVoiceBackend
def test_engine_catalogue_reports_effective_mps_isolation(monkeypatch):
from core.device_caps import HostCaps
from services import tts_backend
monkeypatch.setattr(tts_backend, "_REGISTRY", {"omnivoice": OmniVoiceBackend})
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
)
monkeypatch.setattr(
"engines.omnivoice_subprocess.OmniVoiceSubprocessBackend.is_available",
classmethod(lambda cls: (True, "ready")),
)
row = next(item for item in list_backends() if item["id"] == "omnivoice")
assert row["isolation_mode"] == "subprocess"
def test_mps_startup_does_not_preload_native_model(monkeypatch):
from core.device_caps import HostCaps
from services import model_manager
monkeypatch.setattr(
"core.device_caps.detect_host_caps",
lambda: HostCaps(family="mps", available_families=("mps", "cpu")),
)
monkeypatch.setenv("OMNIVOICE_TTS_BACKEND", "omnivoice")
monkeypatch.setattr(model_manager, "model", None)
async def fail_load():
raise AssertionError("native OmniVoice must not load in the API process on MPS")
monkeypatch.setattr(model_manager, "_load_model_with_timeout", fail_load)
asyncio.run(model_manager.preload_model())
def test_streaming_mps_path_does_not_load_native_model(monkeypatch):
from api.routers.tts_stream import _resolve_stream_backend
from services import model_manager, tts_backend
sentinel = object()
monkeypatch.setattr(tts_backend, "active_backend_id", lambda: "omnivoice")
monkeypatch.setattr(
tts_backend, "get_backend_class", lambda _id: OmniVoiceMPSSubprocessBackend,
)
monkeypatch.setattr(tts_backend, "get_active_tts_backend", lambda: sentinel)
async def fail_load():
raise AssertionError("streaming must not load native OmniVoice on MPS")
monkeypatch.setattr(model_manager, "get_model", fail_load)
assert asyncio.run(_resolve_stream_backend(None)) is sentinel
def test_is_marked_subprocess_isolated():
# list_backends() detects isolation via this duck-typed marker, not issubclass.
assert getattr(OmniVoiceSubprocessBackend, "_is_subprocess_isolated", False) is True
@@ -257,6 +337,21 @@ def test_wedged_sidecar_is_hard_killed_and_recovers(stub_sidecar, monkeypatch):
b.shutdown()
def test_mps_proxy_survives_fatal_child_exit_and_recovers(stub_sidecar, monkeypatch):
_use_stub(monkeypatch, stub_sidecar)
monkeypatch.setattr(
"services.model_manager.make_room_before_generate", lambda: None,
)
b = OmniVoiceMPSSubprocessBackend()
try:
with pytest.raises(RuntimeError, match="backend is still running"):
b.generate("CRASH")
assert b._proc is not None and b._proc.poll() is not None
assert b.generate("ok").shape[1] == 24000
finally:
b.shutdown()
def test_desktop_timeout_kills_engine_subtree_before_late_mutation(
stub_sidecar, monkeypatch, tmp_path
):
@@ -301,3 +396,94 @@ def test_generate_does_not_deadlock_when_called_on_gpu_pool_worker(stub_sidecar,
assert tensor.shape[1] == 24000
finally:
b.shutdown()
def test_sidecar_forwards_native_controls_and_applies_seed(monkeypatch):
import torch
from engines.omnivoice_subprocess import main as sidecar
calls = []
seeds = []
frames = []
class FakeModel:
sampling_rate = 24000
def generate(self, **kwargs):
calls.append(kwargs)
return [torch.zeros(1, 16)]
monkeypatch.setattr(sidecar, "_load_model", lambda _stdout: FakeModel())
monkeypatch.setattr(sidecar, "_send", lambda _stdout, frame: frames.append(frame))
real_manual_seed = torch.manual_seed
monkeypatch.setattr(
torch, "manual_seed", lambda seed: (seeds.append(seed), real_manual_seed(seed))[1],
)
sidecar._handle_synthesize({
"text": "hello",
"seed": 123,
"t_shift": 0.4,
"layer_penalty_factor": 0.2,
"position_temperature": 0.7,
"class_temperature": 0.8,
"audio_chunk_duration": 10,
"audio_chunk_threshold": 0.6,
}, object())
assert seeds == [123]
assert calls == [{
"text": "hello",
"ref_audio": None,
"ref_text": None,
"t_shift": 0.4,
"layer_penalty_factor": 0.2,
"position_temperature": 0.7,
"class_temperature": 0.8,
"audio_chunk_duration": 10,
"audio_chunk_threshold": 0.6,
}]
assert frames[-1]["op"] == "audio"
def test_generation_proxy_forwards_native_controls_and_seed():
import torch
from api.routers.generation import _run_backend_inference
calls = []
class Proxy:
id = "omnivoice"
display_name = "OmniVoice"
sample_rate = 24000
applies_own_mastering = True
supports_native_omnivoice_controls = True
def generate(self, text, **kwargs):
calls.append((text, kwargs))
return torch.zeros(1, 240)
_run_backend_inference(
Proxy(), "hello", "en", None, None, None, None,
16, 2.0, 1.0, False, False, 321,
t_shift=0.4, layer_penalty_factor=0.2,
position_temperature=0.7, class_temperature=0.8,
)
assert calls == [("hello", {
"duration": None,
"language": "en",
"ref_audio": None,
"ref_text": None,
"instruct": None,
"num_step": 16,
"guidance_scale": 2.0,
"speed": 1.0,
"denoise": False,
"postprocess_output": False,
"t_shift": 0.4,
"layer_penalty_factor": 0.2,
"position_temperature": 0.7,
"class_temperature": 0.8,
"seed": 321,
})]
+4
View File
@@ -646,6 +646,10 @@ class TaskExecutor:
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
t_shift=params.get("t_shift"),
layer_penalty_factor=params.get("layer_penalty_factor"),
position_temperature=params.get("position_temperature"),
class_temperature=params.get("class_temperature"),
)
except Exception as exc:
from worker import errors as worker_errors # noqa: PLC0415
+1 -1
View File
@@ -201,7 +201,7 @@ None on the critical path to world-class. All are answers to real demand.
| Kill per-segment disk round-trip | 🟡 | Long-video assembly stays disk-backed to bound RAM. Unchanged same-rate natural segments now skip the redundant decode → scratch encode → decode cycle; fresh segments still persist once and reload for assembly. |
| Cold start ≤1.5 s to first audible sample | 🟡 | Installed models preload in the background and `scripts/bench_pipeline.py` measures cold/warm synthesis; target is not yet verified. |
| Speculative regeneration on hover | ⏳ | — |
| Crash-sandbox engines (subprocess isolation) | 🟡 | Killable sidecar engines and opt-in `omnivoice-subprocess` are live; the default in-process engine can still take down the server on a native crash. |
| Crash-sandbox engines (subprocess isolation) | 🟡 | Killable sidecar engines and opt-in `omnivoice-subprocess` are live; the default OmniVoice engine now routes through the crash sandbox on MPS, while CUDA/ROCm/CPU retain the lower-overhead in-process path. |
| Interaction budgets (<50 ms UI, <200 ms preview, <4 s first seg) | 🟡 | `/ws/tts` reports real TTFA, total generation time and RTF; frontend responsiveness instrumentation exists, but no cross-surface budget gate yet. |
| Dedicated dev-week per quarter | ⏳ | Cadence not yet booked. |
+15 -16
View File
@@ -6,12 +6,12 @@ wedged generation can be hard-killed and its VRAM/device reclaimed.
## Why this engine exists
The default `omnivoice` engine runs in-process on the GPU worker pool. On
VRAM-tight machines (Apple Silicon MPS especially) a heavy generation or model
load can exceed its execution budget. When that happens the worker is
"abandoned" but **cannot be killed** (Python cannot interrupt a native torch /
MPS call), so it keeps holding the GPU device until it finishes on its own, and
every later synth queues behind it and hangs (#730 / #1190).
An in-process `omnivoice` engine runs on the GPU worker pool. On VRAM-tight
machines a heavy generation or model load can exceed its execution budget.
When that happens the worker is "abandoned" but **cannot be killed** (Python
cannot interrupt a native torch call), so it keeps holding the GPU device until
it finishes on its own, and every later synth queues behind it and hangs
(#730 / #1190).
`omnivoice-subprocess` runs the model in a child process spawned via the same
`SubprocessBackend` primitive used by IndexTTS, Supertonic-3, and dots.tts. A
@@ -26,17 +26,18 @@ structurally cannot do.
must recover on its own instead of hanging until a manual restart.
- **VRAM-starved MPS hosts** that hit the abandoned-worker cascade.
For interactive single-shot use on a machine with comfortable VRAM, the default
in-process `omnivoice` engine is faster (no stdio round-trip) and remains the
default.
On Apple Silicon, the default `omnivoice` id automatically uses this isolated
implementation. CUDA, ROCm, and CPU keep the in-process implementation and its
lower call overhead.
## Selecting it
- **Settings -> Engines**, or
- `OMNIVOICE_TTS_BACKEND=omnivoice-subprocess`
It is **opt-in**; the in-process engine stays the default, so existing setups
see no change.
The explicit engine is opt-in on CUDA, ROCm, and CPU. Apple Silicon gets the
same isolation automatically while keeping the default `omnivoice` id in APIs,
Settings, and saved projects.
## Platform support
@@ -54,11 +55,9 @@ see no change.
- A wedged generation is **killed and recovered** at the recv-timeout deadline
(`OMNIVOICE_SIDECAR_RECV_TIMEOUT_S`, default 300s, aligned with the generate
budget) instead of hanging indefinitely.
- It does **not** carry the native advanced-parameter surface
(`t_shift` / `layer_penalty_factor` / `position_temperature` /
`class_temperature`) or parent-side seed determinism, because the generic
engine path does not forward those. For plain voice-clone and design
synthesis this is a non-issue.
- The default Apple Silicon proxy preserves native advanced parameters,
deterministic seeds, and longform quality settings across the process
boundary.
- The recv-timeout deadline is per call and assumes the route's text chunking:
`/generate` and `/v1/audio/speech` split long text into pieces of at most
`max_chunk_chars` before calling the engine, so each call stays short. A
+22
View File
@@ -115,6 +115,28 @@ def test_generic_synth_without_pinned_seed_stays_unseeded(monkeypatch):
assert seeds == [] # fresh-render variety unchanged when nothing is pinned
def test_mps_proxy_keeps_longform_quality_and_child_seed(monkeypatch):
import api.routers.audiobook as ab
import services.tts_backend as tb
calls = []
fake = _fake_backend_cls(calls)
fake.id = "omnivoice"
fake.supports_native_omnivoice_controls = True
monkeypatch.setattr(tb, "active_backend_id", lambda: "omnivoice")
monkeypatch.setattr(tb, "get_backend_class", lambda _id: fake)
monkeypatch.setattr(ab, "_resolve_voice", lambda _vid: {
"ref_audio": None, "ref_text": None, "instruct": None, "seed": 42,
})
ab._build_synth("prof-1")["synth"]("một đoạn văn", None)
_text, kwargs = calls[0]
assert kwargs["num_step"] == ab.LONGFORM_NUM_STEP == 32
assert kwargs["guidance_scale"] == ab.LONGFORM_GUIDANCE_SCALE == 2.0
assert kwargs["seed"] == segment_seed(42, "một đoạn văn")
# ── omnivoice branch: explicit quality preset + pinned seed ──────────────────
def test_omnivoice_synth_pins_quality_preset_and_seed(monkeypatch):