diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5c01e0..c6d3c23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/api/routers/audiobook.py b/backend/api/routers/audiobook.py index 3223332c..d7fb968f 100644 --- a/backend/api/routers/audiobook.py +++ b/backend/api/routers/audiobook.py @@ -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} diff --git a/backend/api/routers/generation.py b/backend/api/routers/generation.py index 205eff70..793aaa8d 100644 --- a/backend/api/routers/generation.py +++ b/backend/api/routers/generation.py @@ -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( diff --git a/backend/api/routers/tts_stream.py b/backend/api/routers/tts_stream.py index 3a21ce04..106124dd 100644 --- a/backend/api/routers/tts_stream.py +++ b/backend/api/routers/tts_stream.py @@ -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 + diff --git a/backend/engines/omnivoice_subprocess/__init__.py b/backend/engines/omnivoice_subprocess/__init__.py index 4fbef65d..7ce2ac32 100644 --- a/backend/engines/omnivoice_subprocess/__init__.py +++ b/backend/engines/omnivoice_subprocess/__init__.py @@ -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"] diff --git a/backend/engines/omnivoice_subprocess/main.py b/backend/engines/omnivoice_subprocess/main.py index 5c07723e..fbd311ed 100644 --- a/backend/engines/omnivoice_subprocess/main.py +++ b/backend/engines/omnivoice_subprocess/main.py @@ -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 ) diff --git a/backend/services/model_manager.py b/backend/services/model_manager.py index ef9bf4c5..c343c612 100644 --- a/backend/services/model_manager.py +++ b/backend/services/model_manager.py @@ -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 diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index 915c827c..bf02c7d9 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -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]: diff --git a/backend/tests/test_omnivoice_subprocess.py b/backend/tests/test_omnivoice_subprocess.py index 955be267..bfede655 100644 --- a/backend/tests/test_omnivoice_subprocess.py +++ b/backend/tests/test_omnivoice_subprocess.py @@ -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, + })] diff --git a/backend/worker/executor.py b/backend/worker/executor.py index cd180665..acf92707 100644 --- a/backend/worker/executor.py +++ b/backend/worker/executor.py @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 095c4835..5084af5f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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. | diff --git a/docs/engines/omnivoice-subprocess.md b/docs/engines/omnivoice-subprocess.md index a7c0c10b..00cedb5f 100644 --- a/docs/engines/omnivoice-subprocess.md +++ b/docs/engines/omnivoice-subprocess.md @@ -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 diff --git a/tests/test_longform_synth_params.py b/tests/test_longform_synth_params.py index 59710d24..0249d1b0 100644 --- a/tests/test_longform_synth_params.py +++ b/tests/test_longform_synth_params.py @@ -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):