Phase 2 Plan 02-03: IndexTTS on SubprocessBackend (closes #42) (#98)

Migrates IndexTTS-2 off the in-process import path and onto the
SubprocessBackend primitive shipped in Plan 02-01. Closes issue #42 with
a structural fix — the parent's transformers>=5.3 and IndexTTS's
transformers<5 now live in separate OS processes and can never collide.

* New: backend/engines/indextts/ — sidecar package (__init__.py hosts
  IndexTTS2Backend, main.py is the sidecar entrypoint, bootstrap.py owns
  the 3-step venv probe + lazy uv-based bootstrap).
* services.tts_backend: IndexTTS2Backend's in-process body removed;
  registry resolves the class lazily via a _LazyRegistry indirection +
  PEP 562 __getattr__ re-export. This breaks the import cycle that
  arose when both subprocess_backend and tts_backend tried to import
  each other at module load.
* docs/engines/indextts.md: install walkthrough + venv resolution order
  + common errors (linked from is_available()'s unavailable message).
* tests:
  - test_indextts_backward_compat.py (8) — probe priority, no-spawn
    discipline, HF cache marker preservation (ENGINE-07).
  - test_indextts_sidecar.py (17) — subclass shape, isolation_mode,
    parent-side emotion arbitration (vector/audio/text/description),
    coexist-with-OmniVoice (headline #42 closure), env forwarding.
  - tests/fixtures/mock_indextts_sidecar.py — stdlib-only sidecar
    mimicking the production wire protocol; emits 1 s sine wave.
  - test_issue_fixes.py: two obsolete in-process-conflict tests rewritten
    to assert the new subprocess contract (no indextts.* import in the
    parent).

Hard constraints honored: backend/services/sonitranslate.py and
gpu_sandbox.py are untouched (D1 / D4). Existing v0.2.7 users with
OMNIVOICE_INDEXTTS_DIR and a populated HF cache reach a working
generation with zero re-download and zero re-install.

44 tests pass across the four exercised files. Full suite: 391 passed,
10 skipped, 13 xfailed, 1 xpassed in 57 s. Smoke: 4 passed.

Closes #42. Requirements: ENGINE-02, ENGINE-03, ENGINE-04, ENGINE-07.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-05-20 07:26:46 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent 0fc5ea6cf3
commit c3695e1668
10 changed files with 2181 additions and 216 deletions
@@ -0,0 +1,163 @@
# Plan 02-03 — Execution Summary
**Phase:** 02 (engine isolation + SubprocessBackend + IndexTTS WAV-export + dubbing)
**Plan:** 02-03 — IndexTTS on SubprocessBackend
**Wave:** 2
**Closes:** ENGINE-02, ENGINE-03, ENGINE-04, ENGINE-07 + issue #42
## Outcome
IndexTTS-2 now runs in its own subprocess + dedicated venv with
`transformers<5`, isolated from the OmniVoice parent process which
keeps its `transformers>=5.3` pin. Issue #42 (the canonical
`OffloadedCache` ImportError) is closed with a structural fix — the
two transformers versions live in different OS processes and can
never collide.
44 tests pass across the four files exercised by this plan:
- `tests/backend/services/test_indextts_backward_compat.py` (8 tests)
- `tests/backend/services/test_indextts_sidecar.py` (17 tests)
- `tests/backend/services/test_subprocess_backend.py` (13 tests)
- `tests/backend/services/test_tts_backend_registry.py` (6 tests)
Full suite: **391 passed, 10 skipped, 13 xfailed, 1 xpassed** in 57 s.
Smoke: 4 passed in 2.25 s. Zero regressions outside of the two
test_issue_fixes.py tests that asserted the *old* in-process error
messages — those were rewritten to validate the new subprocess
contract (`test_indextts_no_inprocess_import_attempted` is now the
direct ENGINE-03 closure assertion).
## Files modified / added
| Path | Purpose | LOC |
|---|---|---|
| `backend/engines/indextts/__init__.py` | Hosts `IndexTTS2Backend(SubprocessBackend)` — see deviation note below | 208 |
| `backend/engines/indextts/main.py` | Sidecar entrypoint; JSON-stdio loop; loads IndexTTS2 lazily | 277 |
| `backend/engines/indextts/bootstrap.py` | 3-step venv probe + lazy `uv venv` + `uv pip install -e` bootstrap | 256 |
| `backend/services/tts_backend.py` | IndexTTS2Backend body removed; lazy registry entry + PEP 562 re-export | net ~150 LOC |
| `tests/backend/services/test_indextts_sidecar.py` | 17 tests — coexistence, env-forwarding, emotion arbitration, source invariants | 350 |
| `tests/backend/services/test_indextts_backward_compat.py` | 8 tests — probe priority, no-spawn discipline, cache-marker preservation | 320 |
| `tests/fixtures/mock_indextts_sidecar.py` | Stdlib-only fixture mimicking the sidecar's wire protocol | 144 |
| `docs/engines/indextts.md` | Install walkthrough + venv-resolution order + common errors | 130 |
| `tests/test_issue_fixes.py` | Two old conflict-detection tests rewritten for the subprocess contract | net ±~15 LOC |
## Class size
The `IndexTTS2Backend` class body in `backend/engines/indextts/__init__.py`
is ~166 lines including its docstring (~50 lines of `"""..."""` plus
~30 lines of comments explaining the parent-side emotion arbitration).
Excluding docstring + comments the executable body is ~70 LOC — above
the plan's "target ≤30 LOC" but justified: the emotion/duration
arbitration (priority of emo_vector > emo_audio > emo_text, the 0.6
cap on text-mode alpha, the description→emo_text fallback) lives
parent-side so the sidecar's wire payload is unambiguous, matching
the old in-process behaviour at the legacy `tts_backend.py:855-907`.
The plan called this out in `<interfaces>` — "Override generate to
translate the public API". Moving the arbitration into the sidecar
would slim the parent class but fragment logic. **Decision:** keep
parent-side; the ~40 extra lines are documented logic the user can
read in one place.
## Deviation from RESEARCH.md sidecar skeleton
The plan's `<interfaces>` block shows `IndexTTS2Backend` defined
inside `backend/services/tts_backend.py`. **I moved the class into
`backend/engines/indextts/__init__.py` to break the import cycle**
between `services.subprocess_backend` (which imports
`TTSBackend` from `services.tts_backend`) and the proposed
`from services.subprocess_backend import SubprocessBackend` at the
top of `tts_backend.py`. The cycle wedged
`tests/backend/services/test_subprocess_backend.py` at collection
time with `ImportError: cannot import name 'SubprocessBackend' from
partially initialized module 'services.subprocess_backend'`.
The lazy-import resolution that DID work:
1. `IndexTTS2Backend` is defined in `backend/engines/indextts/__init__.py`.
2. `services.tts_backend._REGISTRY` is a custom `_LazyRegistry` dict
subclass that resolves `"indextts2"` via deferred
`importlib.import_module("engines.indextts")` on first access.
3. A PEP 562 `__getattr__` at the bottom of `tts_backend.py`
re-exports `IndexTTS2Backend` so legacy callers writing
`from services.tts_backend import IndexTTS2Backend` keep working.
This is the "minimal lazy-import resolution" called for in Plan
02-03 Step 3 — neither side has a circular dependency at import
time; the engines package is only loaded when something asks the
registry for IndexTTS2.
Sidecar ops added beyond the plan's spec: none (the plan's contract
is implemented verbatim — `ready` / `ping` / `synthesize` /
`shutdown` / `error` / `progress`). The emotion kwargs forwarded to
the sidecar are the exact allowlist the plan specified:
`{emo_vector, emo_audio_prompt, emo_alpha, emo_text, use_emo_text,
use_random, target_tokens}`.
## Lazy-import resolution location
Triggered by:
- `services.tts_backend._REGISTRY.items()` / `__getitem__` / `__contains__`
→ resolves via `_LAZY_REGISTRY``importlib.import_module("engines.indextts")`.
- `services.tts_backend.__getattr__("IndexTTS2Backend")` (PEP 562)
→ same import path.
- Test files do `from engines.indextts import bootstrap as
indextts_bootstrap` directly — no resolution path needed; that's
a normal top-level import in a leaf module.
## Venv-probe paths — dev vs. production
The probe order is identical in dev and production:
1. `${OMNIVOICE_INDEXTTS_DIR}/.venv/{bin|Scripts}/python` (highest
priority; preserves zero-friction upgrade for v0.2.7 users)
2. `backend/engines/indextts/.venv/{bin|Scripts}/python`
3. Bootstrap: `uv venv backend/engines/indextts/.venv` +
`uv pip install --python <python> -e ${OMNIVOICE_INDEXTTS_DIR}`
**uv discovery:** `OMNIVOICE_BUNDLED_UV` env var (Tauri-set in
production) → `shutil.which("uv")` (dev / system uv) → raise with
clear install-uv error. The Tauri side does not currently export
`OMNIVOICE_BUNDLED_UV` to the backend; the production code path
falls back to system `uv` for now. A follow-up PR in Phase 2 Wave
3 or Plan 02-04 can wire up the env var in
`frontend/src-tauri/src/lib.rs` when the launcher needs it.
## Test infrastructure
`tests/fixtures/mock_indextts_sidecar.py` exists. It implements the
IndexTTS sidecar op contract using stdlib only — no torch, no
numpy, no indextts dep. The wire protocol matches the production
sidecar byte-for-byte (length-prefixed JSON, 64 MB frame cap,
ready/ping/synthesize/shutdown/probe_env ops). Synthesize replies
with 1 s of 0.5-amp 440 Hz int16 sine wave so the parent's
`torch.max(torch.abs(audio)).item() > 0.3` assertion has signal.
The mock sidecar additionally echoes its received kwargs as
`forwarded_kwargs` in the audio reply — the unit tests use this to
assert the parent-side emotion arbitration constructed the correct
JSON payload (without needing to sniff the raw pipe).
## Threats mitigated
| ID | Mitigation |
|----|------------|
| T-02-08 (HF_TOKEN logging in sidecar) | Sidecar never logs `os.environ`; parent's stderr drain pipes through Phase 1 `HFTokenRedactor` |
| T-02-09 (`uv pip install -e` supply chain) | Accepted — install is from a user-controlled local clone; v0.4 can add hash pinning |
| T-02-10 (model-load DoS) | Sidecar emits `progress` frames at 0/50/100% during the ~20 s cold load; Compat Matrix UI will surface them |
| T-02-11 (tempfile leak) | `tempfile.NamedTemporaryFile(suffix=".wav")` + `os.unlink` in `finally`; OS reaps stragglers |
## D1 / D4 locked decisions
- `backend/services/sonitranslate.py` — **unchanged** (verified by `git diff`).
- `backend/services/gpu_sandbox.py` — **unchanged** (verified by `git diff`).
## ENGINE-XX closure mapping
| Req | Evidence |
|-----|----------|
| ENGINE-02 | `test_hf_home_marker_present_after_bootstrap` — HF cache survives bootstrap byte-for-byte; `SubprocessBackend._spawn` uses `os.environ.copy()` so HF_HOME/HF_HUB_CACHE/HF_ENDPOINT/HF_TOKEN reach the sidecar (verified by `test_env_forwarding_to_indextts_sidecar`) |
| ENGINE-03 | `IndexTTS2Backend` is now a `SubprocessBackend` subclass; `test_indextts_no_inprocess_import_attempted` proves no `import indextts.*` ever fires in the parent process |
| ENGINE-04 | `test_coexist_with_omnivoice_in_one_session` — OmniVoiceBackend + IndexTTS2Backend both serve `generate()` in the same Python interpreter |
| ENGINE-07 | `test_venv_probe_prefers_omnivoice_indextts_dir` — existing v0.2.7 users with `OMNIVOICE_INDEXTTS_DIR + .venv` reach a working generation with zero re-download and zero re-install |
+208
View File
@@ -0,0 +1,208 @@
"""IndexTTS-2 sidecar package (Phase 2 Plan 02-03).
IndexTTS-2 runs in its own subprocess + dedicated venv with
``transformers<5``, isolated from the OmniVoice parent process which
pins ``transformers>=5.3``. Closes issue #42 — the canonical
``OffloadedCache`` ImportError driven by the transformers v4 ↔ v5
incompatibility — by making the two libraries live in separate OS
processes.
Three public entry points live in this package:
* ``IndexTTS2Backend`` (this module) — the SubprocessBackend subclass
that ``services.tts_backend._REGISTRY`` resolves lazily on first
access. The class is defined HERE rather than inside
``services.tts_backend`` because importing ``SubprocessBackend`` at
that module's top level would cycle with
``services.subprocess_backend``'s ``from services.tts_backend import
TTSBackend`` line. Defining the class in this package breaks the
cycle: ``services.tts_backend`` finishes loading before anything
here is imported.
* ``main.py`` — the sidecar entrypoint (runs under a different venv).
* ``bootstrap.py`` — the venv-probe + lazy-bootstrap helper.
Do NOT import ``main.py`` from the parent process — it runs under a
different venv and may not have access to the parent's installed
packages. The parent only ever spawns it as a subprocess.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
if TYPE_CHECKING:
import torch # noqa: F401
logger = logging.getLogger("omnivoice.indextts")
class IndexTTS2Backend(SubprocessBackend):
"""IndexTTS2 (Bilibili) — runs in its own subprocess + dedicated venv.
Plan 02-03 migrated IndexTTS off the in-process import path because
IndexTTS pins ``transformers<5`` while OmniVoice pins
``transformers>=5.3``. The two cannot share a Python interpreter
without one of them blowing up at import time (issue #42 — the
canonical ``OffloadedCache`` ImportError). Running IndexTTS in a
subprocess with its own venv lets both libraries co-exist.
Key differentiators preserved from the in-process incarnation:
* **Emotion decoupling** — clone timbre from one reference, apply
emotion from a completely separate source (audio, 8-float
vector, or text).
* **Duration control** — first AR model to precisely target
output length (critical for video dubbing lip-sync).
* **8-float emotion vector** — [happy, angry, sad, afraid,
disgusted, melancholic, surprised, calm] — each 0.01.0.
* **Text-based emotion** — natural-language emotion descriptions
via a fine-tuned Qwen3 encoder, ``emo_alpha`` capped at 0.6.
Installation (transparent to existing v0.2.7 users — ENGINE-07)::
git clone https://github.com/index-tts/index-tts.git
cd index-tts && uv pip install -e . # NOT uv sync --all-extras
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
Set ``OMNIVOICE_INDEXTTS_DIR`` to the repo root. OmniVoice will
create ``backend/engines/indextts/.venv`` lazily on first launch if
no venv exists yet — the user's existing
``${OMNIVOICE_INDEXTTS_DIR}/.venv`` is preferred if present, so no
re-install is needed.
License: Custom (Bilibili) — free for research/non-commercial.
Commercial use requires contacting indexspeech@bilibili.com.
"""
id = "indextts2"
display_name = "IndexTTS2 (emotion control, duration control, zero-shot)"
supports_voice_design = False # requires ref audio for timbre
_DEFAULT_SAMPLE_RATE = 24000
@classmethod
def is_available(cls) -> tuple[bool, str]:
# IMPORTANT: do NOT attempt ``import indextts`` here. The parent's
# transformers>=5.3 cannot coexist with IndexTTS's transformers<5
# in one interpreter — that's the entire reason this backend
# lives in a subprocess. We only verify the venv exists on disk
# and the sidecar script ships with the install. Health-checking
# the sidecar is gated on user action (Settings → 'Test engine').
from engines.indextts.bootstrap import (
INDEXTTS_SIDECAR_SCRIPT,
is_indextts_installed,
)
if not is_indextts_installed():
return False, (
"IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR to "
"your IndexTTS clone (the directory containing checkpoints/) "
"and restart OmniVoice. See docs/engines/indextts.md for the "
"full install walk-through."
)
if not INDEXTTS_SIDECAR_SCRIPT.exists():
return False, (
"IndexTTS sidecar script missing at "
f"{INDEXTTS_SIDECAR_SCRIPT} — reinstall OmniVoice."
)
return True, "ok"
@classmethod
def venv_python(cls):
from engines.indextts.bootstrap import resolve_indextts_venv
return resolve_indextts_venv()
@classmethod
def sidecar_script(cls):
from engines.indextts.bootstrap import INDEXTTS_SIDECAR_SCRIPT
return INDEXTTS_SIDECAR_SCRIPT
@property
def sample_rate(self) -> int:
# Advertised by the sidecar's ready frame; pinned at the class
# level so callers (engine picker, dub pipeline) can query
# without spawning the sidecar.
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
# Primarily Chinese + English with multilingual prompt handling.
return ["zh", "en"]
# ── parent-side emotion / duration arbitration ─────────────────────
#
# The sidecar accepts any of: emo_vector, emo_audio_prompt+emo_alpha,
# emo_text+use_emo_text+emo_alpha+use_random. We do the priority
# arbitration here so the wire payload is unambiguous and the
# sidecar's dispatch stays narrow (mirrors the legacy in-process
# arbitration at the old tts_backend.py:855-907).
#
# Override ``generate`` to translate the public ``generate(text,
# **kw)`` API into the sidecar's synthesize op. The base class's
# ``generate`` would still work (it forwards every JSON-safe
# kwarg), but the priority arbitration would land in the sidecar,
# fragmenting logic. Keeping it parent-side also lets us drop
# ``description → emo_text`` without the sidecar knowing what
# ``description`` means.
def generate(self, text: str, **kw) -> "torch.Tensor":
ref_audio = kw.get("ref_audio")
if not ref_audio:
raise RuntimeError(
"IndexTTS2 requires a reference audio for voice cloning "
"(timbre). Pass ref_audio= with a path to a speaker "
"reference clip."
)
emo_vector = kw.get("emo_vector")
emo_audio = kw.get("emo_audio")
emo_text = kw.get("emo_text")
emo_alpha = float(kw.get("emo_alpha", 1.0))
use_random = bool(kw.get("use_random", False))
# Voice-design fallback: if ``description`` came in via the
# OpenAI-compatible TTS route, treat it as a text emotion prompt.
description = kw.get("description")
if description and not emo_text and not emo_vector and not emo_audio:
emo_text = description
forwarded: dict = {"ref_audio": ref_audio}
# Duration control — codec frame rate ≈ 21 Hz.
duration = kw.get("duration")
if duration is not None:
target_tokens = int(float(duration) * 21)
if target_tokens > 0:
forwarded["target_tokens"] = target_tokens
if (
emo_vector
and isinstance(emo_vector, (list, tuple))
and len(emo_vector) == 8
):
forwarded["emo_vector"] = [float(v) for v in emo_vector]
forwarded["use_random"] = use_random
logger.info(
"IndexTTS2: emotion via vector %s", forwarded["emo_vector"],
)
elif emo_audio:
forwarded["emo_audio_prompt"] = emo_audio
forwarded["emo_alpha"] = emo_alpha
logger.info(
"IndexTTS2: emotion via audio ref (alpha=%.2f)", emo_alpha,
)
elif emo_text:
forwarded["emo_text"] = emo_text
forwarded["use_emo_text"] = True
forwarded["emo_alpha"] = min(emo_alpha, 0.6)
forwarded["use_random"] = use_random
logger.info(
"IndexTTS2: emotion via text description: %r (alpha=%.2f)",
emo_text[:60], forwarded["emo_alpha"],
)
# Delegate to SubprocessBackend.generate which handles the JSON
# round-trip, GPU slot acquire/release, and int16 PCM decode.
return super().generate(text, **forwarded)
__all__ = ["IndexTTS2Backend"]
+276
View File
@@ -0,0 +1,276 @@
"""IndexTTS-2 venv probe + lazy bootstrap (Phase 2 Plan 02-03).
The parent process needs to know *which Python interpreter* to spawn the
IndexTTS sidecar under. This module owns that resolution. The probe runs
in three steps, in priority order, so the experience for existing
v0.2.7 users is transparent (their existing clone + venv is reused
verbatim — no re-download of the 6 GB model, no re-install of the
indextts package).
Probe order (Open Question #1 resolution from 02-RESEARCH.md):
1. ``${OMNIVOICE_INDEXTTS_DIR}/.venv/`` (or ``Scripts\\python.exe`` on
Windows). Highest priority — power users who already cloned
IndexTTS and ran ``uv pip install -e .`` get zero migration cost.
2. ``backend/engines/indextts/.venv/`` — this package's own venv,
created by step 3 if needed. Survives across OmniVoice upgrades;
the IndexTTS clone is referenced via ``uv pip install -e`` so
weights and code live in the user's clone, not under OmniVoice.
3. Bootstrap: run ``uv venv`` then ``uv pip install -e
${OMNIVOICE_INDEXTTS_DIR}`` to populate step-2's venv. Requires
OMNIVOICE_INDEXTTS_DIR to be set (otherwise we don't know where
the IndexTTS clone is); we raise with a clear error message that
points at the install docs.
Caching: the resolution is memoised after the first successful call.
Tests reset the cache via :func:`invalidate`.
Threat model (Plan 02-03 frontmatter):
T-02-08 — sidecar HF_TOKEN logging:
Bootstrap never touches the token; the sidecar's stderr is
drained by SubprocessBackend through the parent root logger
where the Phase 1 ``HFTokenRedactor`` filter strips token bytes.
T-02-09 — supply chain (uv pip install -e):
Bootstrap installs from a user-controlled local directory
(``OMNIVOICE_INDEXTTS_DIR``). The user already trusts that
directory's contents (it's their own clone). Accepted for v0.3;
revisit in v0.4 with hash-pinned indextts requirements.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger("omnivoice.indextts.bootstrap")
# Absolute path to the sidecar entrypoint. ``IndexTTS2Backend.sidecar_script``
# returns this; SubprocessBackend spawns it with the resolved venv python.
INDEXTTS_SIDECAR_SCRIPT: Path = Path(__file__).parent / "main.py"
# Path to this package's owned venv (Probe 2). The IndexTTS clone, when
# bootstrapped, is installed into this venv via ``uv pip install -e``.
_ENGINES_VENV_DIR: Path = Path(__file__).parent / ".venv"
# Per-process resolution cache. Cleared by :func:`invalidate` for tests.
_resolved_python: Optional[Path] = None
# Timeouts. ``_venv_can_import_indextts`` is a bounded probe so we never
# hang waiting on a broken venv; the bootstrap install can take minutes
# on a cold cache (indextts pulls torch, transformers<5, etc.).
_IMPORT_PROBE_TIMEOUT_S = 10
_UV_VENV_TIMEOUT_S = 120
_UV_PIP_INSTALL_TIMEOUT_S = 900
# ── public API ────────────────────────────────────────────────────────────
def invalidate() -> None:
"""Clear the resolved-python cache. Tests call this between scenarios."""
global _resolved_python
_resolved_python = None
def is_indextts_installed() -> bool:
"""Quick file-existence check for a usable IndexTTS venv.
Returns True if either Probe 1 or Probe 2 has a Python executable on
disk. Does NOT spawn the sidecar Python and does NOT verify that
``import indextts`` actually succeeds — that's expensive enough that
we save it for :func:`resolve_indextts_venv`, which is only invoked
on the first generate() / health_check(). This function fires on
every Settings page render via ``IndexTTS2Backend.is_available()``,
so it stays cheap.
"""
for cand in _probe_paths():
if cand.is_file():
return True
return False
def resolve_indextts_venv() -> Path:
"""Resolve the path to the Python interpreter that runs the sidecar.
Probe order described in the module docstring. Memoised. Raises
:exc:`RuntimeError` if no working venv can be located AND the
bootstrap path is unavailable.
"""
global _resolved_python
if _resolved_python is not None:
return _resolved_python
# Probe 1 — user's clone-level venv (highest priority for back-compat).
omv_dir = os.environ.get("OMNIVOICE_INDEXTTS_DIR")
if omv_dir:
cand = _venv_python_path(Path(omv_dir) / ".venv")
if cand.is_file() and _venv_can_import_indextts(cand):
logger.info(
"IndexTTS venv resolved from OMNIVOICE_INDEXTTS_DIR: %s", cand,
)
_resolved_python = cand
return cand
# Probe 2 — this package's own venv.
cand = _venv_python_path(_ENGINES_VENV_DIR)
if cand.is_file() and _venv_can_import_indextts(cand):
logger.info("IndexTTS venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
# Probe 3 — bootstrap.
if not omv_dir:
raise RuntimeError(
"IndexTTS-2 is not installed. Set the OMNIVOICE_INDEXTTS_DIR "
"environment variable to your IndexTTS clone (the directory "
"that contains checkpoints/ and pyproject.toml), then restart "
"OmniVoice. See docs/engines/indextts.md for the full install "
"walk-through."
)
cand = _bootstrap_engines_venv(Path(omv_dir))
_resolved_python = cand
return cand
# ── internals ─────────────────────────────────────────────────────────────
def _venv_python_path(venv_dir: Path) -> Path:
"""Return the python executable path inside a venv directory.
Handles the Unix (``bin/python``) vs Windows (``Scripts/python.exe``)
layout. No filesystem access — caller checks .is_file().
"""
if sys.platform == "win32":
return venv_dir / "Scripts" / "python.exe"
return venv_dir / "bin" / "python"
def _probe_paths() -> list[Path]:
"""Ordered list of candidate venv-python paths (no .is_file() check)."""
out: list[Path] = []
omv_dir = os.environ.get("OMNIVOICE_INDEXTTS_DIR")
if omv_dir:
out.append(_venv_python_path(Path(omv_dir) / ".venv"))
out.append(_venv_python_path(_ENGINES_VENV_DIR))
return out
def _venv_can_import_indextts(python_path: Path) -> bool:
"""Spawn the candidate python and verify ``import indextts.infer_v2`` works.
Bounded by ``_IMPORT_PROBE_TIMEOUT_S`` so a wedged venv never hangs
the parent. Returns False on any failure (non-zero exit, timeout,
OSError).
"""
try:
proc = subprocess.run(
[str(python_path), "-c", "import indextts.infer_v2"],
capture_output=True,
timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("indextts import probe failed for %s: %s", python_path, exc)
return False
if proc.returncode != 0:
logger.debug(
"indextts import probe non-zero for %s: %s",
python_path,
proc.stderr.decode("utf-8", errors="replace")[:200],
)
return False
return True
def _locate_uv() -> Optional[str]:
"""Find the uv binary — bundled first (Tauri-set env var), else PATH."""
bundled = os.environ.get("OMNIVOICE_BUNDLED_UV")
if bundled and Path(bundled).is_file():
return bundled
sys_uv = shutil.which("uv")
if sys_uv:
return sys_uv
return None
def _bootstrap_engines_venv(indextts_clone: Path) -> Path:
"""Create engines/indextts/.venv and install the user's clone into it.
Runs ``uv venv <engines_venv>`` then ``uv pip install --python
<engines_venv>/bin/python -e <indextts_clone>``. Verifies the result
by re-probing the import — a successful uv invocation that still
can't import indextts indicates a deeper environment problem and
we raise with whatever stderr we captured.
"""
uv = _locate_uv()
if not uv:
raise RuntimeError(
"uv is required to bootstrap the IndexTTS-2 venv but was not "
"found on PATH (and the bundled uv path was not set via the "
"OMNIVOICE_BUNDLED_UV env var). Install uv from "
"https://docs.astral.sh/uv/ and re-launch OmniVoice, or set "
"OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary."
)
logger.info(
"Bootstrapping IndexTTS venv at %s from %s (this can take several minutes on first launch)",
_ENGINES_VENV_DIR, indextts_clone,
)
try:
subprocess.run(
[uv, "venv", str(_ENGINES_VENV_DIR)],
check=True,
timeout=_UV_VENV_TIMEOUT_S,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"uv venv failed for IndexTTS bootstrap at {_ENGINES_VENV_DIR}: "
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
python_path = _venv_python_path(_ENGINES_VENV_DIR)
try:
subprocess.run(
[
uv, "pip", "install",
"--python", str(python_path),
"-e", str(indextts_clone),
],
check=True,
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
"uv pip install -e failed during IndexTTS bootstrap "
f"({indextts_clone}): "
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
if not _venv_can_import_indextts(python_path):
raise RuntimeError(
"IndexTTS bootstrap completed but `import indextts.infer_v2` "
f"still fails from {python_path}. Verify that "
f"{indextts_clone} is a valid IndexTTS clone (contains "
"pyproject.toml with the indextts package). See "
"docs/engines/indextts.md."
)
logger.info("IndexTTS venv bootstrap successful: %s", python_path)
return python_path
__all__ = [
"INDEXTTS_SIDECAR_SCRIPT",
"invalidate",
"is_indextts_installed",
"resolve_indextts_venv",
]
+309
View File
@@ -0,0 +1,309 @@
"""IndexTTS-2 sidecar entry point (Phase 2 Plan 02-03).
Runs inside ``engines/indextts/.venv`` (or the user's existing
``${OMNIVOICE_INDEXTTS_DIR}/.venv``) with ``transformers<5``, isolated
from the OmniVoice parent process which pins ``transformers>=5.3``.
Closes issue #42 — the canonical ``OffloadedCache`` ImportError that
results from running both libraries inside one Python interpreter.
This script is stdlib-only at import time. It imports the indextts
library lazily on the first synthesize op so the sidecar can emit a
``ready`` frame within the parent's 30 s spawn handshake even when the
model itself takes ~20 s cold-load (RESEARCH.md Pitfall 8). Any import
failure surfaces as an ``error`` frame with full traceback before the
sidecar exits 1 — the parent's stderr drain + the operator's logs will
also have the underlying ImportError text.
Wire protocol — length-prefixed JSON over stdin/stdout, byte-identical
to ``backend/services/subprocess_backend.py``::
[ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ]
Op flow expected by the parent:
1. Sidecar -> parent: {"op": "ready", "engine": "indextts2",
"sample_rate": 24000}
(Model NOT yet loaded — that happens on the first synthesize op
per Pitfall 8. The ready frame is just the handshake.)
2. Optional: parent -> sidecar: {"op": "ping"} ->
sidecar -> parent: {"op": "pong"}
3. Parent -> sidecar: {"op": "synthesize", "text": "...",
"ref_audio": "/path/to/spk.wav",
"emo_vector": [..], "emo_audio": "...",
"emo_text": "...", "emo_alpha": 1.0,
"use_random": false, "duration": 3.4}
Sidecar emits one or more {"op": "progress",
"stage": "loading_model",
"percent": N} frames during the cold
model construction, then:
sidecar -> parent: {"op": "audio",
"audio_pcm_b64": "<base64 int16>",
"sample_rate": 24000,
"n_samples": N}
4. Parent -> sidecar: {"op": "shutdown"} -> exit 0
5. Unknown op -> {"op": "error", "stage": "dispatch",
"message": "unknown op: <op>"} and continue.
Restrictions:
* NO imports from ``backend.services``, ``backend.engines`` (other
than this package), or any OmniVoice parent code. The sidecar runs
under a venv where those modules may not resolve.
* NO logging of ``os.environ`` contents or env-var values. Defense in
depth against accidental token-bytes-on-stderr (T-02-08); the
parent's stderr drainer additionally pipes everything through the
Phase 1 ``HFTokenRedactor`` filter.
* Single-frame DoS cap matches the parent's ``MAX_FRAME_BYTES`` so a
malformed inbound frame surfaces as a clean IOError instead of an
OOM.
"""
from __future__ import annotations
import base64
import json
import os
import struct
import sys
import tempfile
import traceback
# Mirrors backend/services/subprocess_backend.py::MAX_FRAME_BYTES.
MAX_FRAME_BYTES = 64 * 1024 * 1024
# Sample rate IndexTTS-2 emits natively. Advertised in the ready frame so
# the parent doesn't have to import IndexTTS just to learn the rate.
INDEXTTS_SAMPLE_RATE = 24000
# Allowlist of kwargs we forward to ``IndexTTS2.infer``. Mirrors the old
# in-process ``IndexTTS2Backend.generate`` body at
# ``backend/services/tts_backend.py::IndexTTS2Backend.generate`` so the
# emotion / duration / random kwargs survive the migration verbatim.
# Anything not in this set is silently dropped before the call.
EMOTION_KWARGS_ALLOWLIST = frozenset({
"emo_vector", # list[float] len=8
"emo_audio_prompt", # path to emotion ref wav
"emo_alpha", # float, emotion blend strength
"emo_text", # str, natural-language emotion
"use_emo_text", # bool — set by parent when emo_text supplied
"use_random", # bool
"target_tokens", # int — duration control
})
# ── wire protocol ─────────────────────────────────────────────────────────
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
# ── model loading (lazy, on first synthesize) ─────────────────────────────
# Module-level singleton — populated on the first synthesize op and reused
# for every subsequent request in this sidecar's lifetime.
_model = None
def _load_model(stdout) -> object:
"""Cold-construct IndexTTS2 from OMNIVOICE_INDEXTTS_DIR/checkpoints/.
Emits ``progress`` frames at 0/50/100% so the parent can surface the
20+ second model-load latency in the Compat Matrix UI (T-02-10). On
failure raises — the caller emits an ``error`` frame for the
in-flight synthesize op and continues the dispatch loop (the next
request retries the load).
"""
global _model
if _model is not None:
return _model
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
# Imported lazily so a missing dep doesn't block the ready handshake.
from indextts.infer_v2 import IndexTTS2 # type: ignore[import-not-found]
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50})
repo_dir = os.environ.get("OMNIVOICE_INDEXTTS_DIR", ".")
cfg_path = os.path.join(repo_dir, "checkpoints", "config.yaml")
model_dir = os.path.join(repo_dir, "checkpoints")
use_fp16 = os.environ.get("OMNIVOICE_INDEXTTS_FP16", "1") == "1"
_model = IndexTTS2(
cfg_path=cfg_path,
model_dir=model_dir,
use_fp16=use_fp16,
use_cuda_kernel=False,
use_deepspeed=False,
)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _model
def _wav_to_pcm_b64(wav_path: str) -> tuple[str, int, int]:
"""Read a WAV file, downmix to mono, return base64 int16 PCM.
Returns (b64_pcm, sample_rate, n_samples). Uses torchaudio because
the sidecar's venv already has torch as a dep of indextts — no extra
install cost.
"""
import numpy as np
import torchaudio # type: ignore[import-not-found]
wav, sr = torchaudio.load(wav_path)
# Downmix multi-channel to mono.
if wav.ndim == 2 and wav.shape[0] > 1:
wav = wav.mean(dim=0, keepdim=True)
if wav.ndim == 1:
wav = wav.unsqueeze(0)
# Resample to IndexTTS's advertised rate if the model emitted something
# different (it shouldn't, but defensive — the parent caches our
# advertised sample_rate from the ready frame and decodes accordingly).
if int(sr) != INDEXTTS_SAMPLE_RATE:
wav = torchaudio.functional.resample(wav, sr, INDEXTTS_SAMPLE_RATE)
sr = INDEXTTS_SAMPLE_RATE
arr = wav.squeeze(0).cpu().numpy()
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sr), int(arr.shape[0])
def _handle_synthesize(msg: dict, stdout) -> None:
"""Dispatch one synthesize request. Emits the audio frame or raises."""
text = msg.get("text")
if not text:
raise ValueError("synthesize: missing 'text' field")
ref_audio = msg.get("ref_audio")
if not ref_audio:
raise ValueError(
"synthesize: IndexTTS2 requires a 'ref_audio' path for voice cloning"
)
model = _load_model(stdout)
# Build infer_kwargs by filtering through the allowlist. The parent
# has already done any vector-vs-audio-vs-text emotion priority
# arbitration; we just forward whichever keys it sent.
infer_kw: dict = {
"spk_audio_prompt": ref_audio,
"text": text,
"verbose": False,
}
for k, v in msg.items():
if k in EMOTION_KWARGS_ALLOWLIST and v is not None:
infer_kw[k] = v
# IndexTTS2.infer() writes to a file; we route through tempfile so
# cleanup is automatic on success and on exit.
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
try:
infer_kw["output_path"] = tmp_path
model.infer(**infer_kw)
pcm_b64, sr, n_samples = _wav_to_pcm_b64(tmp_path)
finally:
try:
os.unlink(tmp_path)
except OSError:
# T-02-11 — failure to unlink is logged-as-debug at most; the
# OS will reap the temp file at process exit. Never break the
# response on a cleanup error.
pass
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": sr,
"n_samples": n_samples,
})
# ── main loop ─────────────────────────────────────────────────────────────
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# The ready handshake fires BEFORE any heavy import. SubprocessBackend's
# SPAWN_READY_TIMEOUT_S is 30 s; we comfortably make that even on a
# cold filesystem because nothing above this line touches indextts.
_send(stdout, {
"op": "ready",
"engine": "indextts2",
"sample_rate": INDEXTTS_SAMPLE_RATE,
})
while True:
try:
msg = _recv(stdin)
except Exception as exc:
# Wire-level failure — we can't trust further reads. Surface
# the error frame, then exit 1 so the parent respawns next time.
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
# Clean EOF — parent closed stdin (shutdown path bypassed
# because the shutdown op already triggered our return).
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong"})
elif op == "synthesize":
_handle_synthesize(msg, stdout)
elif op == "shutdown":
return 0
else:
_send(stdout, {
"op": "error",
"stage": "dispatch",
"message": f"unknown op: {op!r}",
})
except Exception as exc:
# Per-op failure is recoverable — emit the error frame and
# stay alive so the parent can retry without paying the
# ~20 s respawn cost.
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
+106 -196
View File
@@ -734,201 +734,33 @@ class CosyVoiceBackend(TTSBackend):
return wav
# ── IndexTTS2 adapter (emotion control + duration control) ──────────────────
# ── IndexTTS2 adapter ───────────────────────────────────────────────────────
#
# The concrete class lives in ``backend/engines/indextts/__init__.py`` so
# that ``services.tts_backend`` itself does NOT import
# ``services.subprocess_backend`` at module load time. That separation
# breaks the import cycle:
#
# services.subprocess_backend ──imports──> services.tts_backend (TTSBackend)
# services.tts_backend ──exports──> TTSBackend + registry
# engines.indextts ──imports──> services.subprocess_backend
# ──exports──> IndexTTS2Backend
#
# The registry below resolves IndexTTS2Backend lazily via the
# ``_LAZY_REGISTRY`` indirection — see ``get_backend_class`` and
# ``list_backends``. This was driven by Plan 02-03 (Step 3); see
# ``engines/indextts/__init__.py`` for the actual class body.
class IndexTTS2Backend(TTSBackend):
"""IndexTTS2 (Bilibili) — industrial zero-shot TTS with emotion and
duration control.
Key differentiators vs every other engine in the registry:
• **Emotion decoupling** — clone timbre from one reference, apply emotion
from a completely separate source (audio, 8-float vector, or text).
• **Duration control** — first AR model to precisely target output length
(critical for video dubbing lip-sync).
• **8-float emotion vector** — [happy, angry, sad, afraid, disgusted,
melancholic, surprised, calm] — each 0.01.0.
• **Text-based emotion** — pass natural-language emotion descriptions
(e.g. "terrified and panicking") via a fine-tuned Qwen3 encoder.
Installation:
git clone https://github.com/index-tts/index-tts.git
cd index-tts && uv pip install -e .
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
⚠️ Do NOT use ``uv sync --all-extras`` — it overwrites OmniVoice's lock
file and replaces transformers>=5.3 with transformers<5, breaking OmniVoice.
Use ``uv pip install -e .`` instead to add IndexTTS without clobbering deps.
On Windows, ``--all-extras`` also fails because deepspeed cannot compile.
Set ``OMNIVOICE_INDEXTTS_DIR`` to the repo root (containing ``checkpoints/``).
License: Custom (Bilibili) — free for research/non-commercial. Commercial
use requires contacting indexspeech@bilibili.com.
"""
id = "indextts2"
display_name = "IndexTTS2 (emotion control, duration control, zero-shot)"
supports_voice_design = False # requires ref audio for timbre
def __init__(self):
self._model = None
@classmethod
def is_available(cls) -> tuple[bool, str]:
try:
from indextts.infer_v2 import IndexTTS2 as _Model # noqa: F401
return True, "ready"
except ImportError as e:
err = str(e)
# Detect the transformers version conflict specifically
if "transformers" in err or "OffloadedCache" in err or "HiggsAudio" in err:
return False, (
f"IndexTTS dependency conflict: {err}. "
"IndexTTS requires transformers<5 but OmniVoice needs "
"transformers>=5.3. Install IndexTTS in a separate venv "
"and run it as a sidecar process, or use "
"`uv pip install -e .` (not `uv sync --all-extras`) "
"to avoid overwriting OmniVoice's lock file."
)
return False, (
"indextts package not installed. Clone the repo and install: "
"git clone https://github.com/index-tts/index-tts.git && "
"cd index-tts && uv pip install -e . "
"(Note: use `uv pip install -e .` instead of `uv sync --all-extras` "
"to avoid overwriting OmniVoice dependencies). Then set "
"OMNIVOICE_INDEXTTS_DIR to the repo root."
)
except Exception as e:
# Catch deeper crashes from the import chain (e.g. transformers
# internal ImportError that surfaces as a regular Exception)
return False, (
f"IndexTTS failed to load: {e}. This is usually caused by "
"a transformers version conflict (IndexTTS needs <5, OmniVoice "
"needs >=5.3). Consider running IndexTTS in a separate venv."
)
@property
def sample_rate(self) -> int:
# IndexTTS2 outputs 24 kHz by default
return 24000
@property
def supported_languages(self) -> list[str]:
# Primarily Chinese + English, but can handle multilingual via prompts
return ["zh", "en"]
def _ensure_loaded(self):
if self._model is not None:
return
ok, msg = self.is_available()
if not ok:
raise RuntimeError(f"IndexTTS2 unavailable: {msg}")
from indextts.infer_v2 import IndexTTS2 # type: ignore[import-not-found]
repo_dir = os.environ.get("OMNIVOICE_INDEXTTS_DIR", ".")
cfg_path = os.path.join(repo_dir, "checkpoints", "config.yaml")
model_dir = os.path.join(repo_dir, "checkpoints")
use_fp16 = os.environ.get("OMNIVOICE_INDEXTTS_FP16", "1") == "1"
logger.info(
"Loading IndexTTS2 from %s (fp16=%s)", model_dir, use_fp16,
)
self._model = IndexTTS2(
cfg_path=cfg_path,
model_dir=model_dir,
use_fp16=use_fp16,
use_cuda_kernel=False,
use_deepspeed=False,
)
def generate(self, text: str, **kw) -> torch.Tensor:
self._ensure_loaded()
import numpy as np
import tempfile
ref_audio = kw.get("ref_audio")
if not ref_audio:
raise RuntimeError(
"IndexTTS2 requires a reference audio for voice cloning (timbre). "
"Pass ref_audio= with a path to a speaker reference clip."
)
# ── Emotion control ────────────────────────────────────────────
# IndexTTS2 supports 3 emotion modalities — we check in priority order:
# 1. emo_vector: explicit 8-float list
# 2. emo_audio: separate emotion reference audio
# 3. emo_text / description: natural-language emotion description
emo_vector = kw.get("emo_vector") # list[float] len=8
emo_audio = kw.get("emo_audio") # path to emotion ref audio
emo_text = kw.get("emo_text") # text emotion description
emo_alpha = float(kw.get("emo_alpha", 1.0)) # emotion blending strength
use_random = bool(kw.get("use_random", False))
# Fall back: if `description` is set (from OpenAI API / voice design),
# treat it as an emotion text instruction.
description = kw.get("description")
if description and not emo_text and not emo_vector and not emo_audio:
emo_text = description
# Build the infer kwargs
infer_kw: dict = {
"spk_audio_prompt": ref_audio,
"text": text,
"verbose": False,
}
# Duration control — the killer feature for video dubbing sync.
# When the dub pipeline passes `duration=`, we convert seconds to
# the token count IndexTTS2 expects. The model's codec runs at ~21 Hz.
duration = kw.get("duration")
if duration is not None:
# IndexTTS2 uses target_tokens for duration control.
# Approximate: codec frame rate ≈ 21 Hz
target_tokens = int(float(duration) * 21)
if target_tokens > 0:
infer_kw["target_tokens"] = target_tokens
# Apply emotion modality
if emo_vector and isinstance(emo_vector, (list, tuple)) and len(emo_vector) == 8:
infer_kw["emo_vector"] = [float(v) for v in emo_vector]
infer_kw["use_random"] = use_random
logger.info("IndexTTS2: emotion via vector %s", infer_kw["emo_vector"])
elif emo_audio:
infer_kw["emo_audio_prompt"] = emo_audio
infer_kw["emo_alpha"] = emo_alpha
logger.info("IndexTTS2: emotion via audio ref (alpha=%.2f)", emo_alpha)
elif emo_text:
infer_kw["use_emo_text"] = True
infer_kw["emo_text"] = emo_text
infer_kw["emo_alpha"] = min(emo_alpha, 0.6) # recommended ≤0.6 for text mode
infer_kw["use_random"] = use_random
logger.info(
"IndexTTS2: emotion via text description: %r (alpha=%.2f)",
emo_text[:60], infer_kw["emo_alpha"],
)
# IndexTTS2.infer() writes to a file, so we use a temp path and read back.
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
try:
infer_kw["output_path"] = tmp_path
self._model.infer(**infer_kw)
# Read back the generated audio
import torchaudio
wav, sr = torchaudio.load(tmp_path)
if sr != self.sample_rate:
wav = torchaudio.functional.resample(wav, sr, self.sample_rate)
if wav.ndim == 1:
wav = wav.unsqueeze(0)
elif wav.ndim == 2 and wav.shape[0] > 1:
wav = wav.mean(dim=0, keepdim=True)
return wav
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# ``IndexTTS2Backend`` is re-exported from ``backend/engines/indextts``
# via the module-level ``__getattr__`` hook at the bottom of this file
# (PEP 562). Callers can still write::
#
# from services.tts_backend import IndexTTS2Backend
#
# and they receive the same class object as ``engines.indextts.IndexTTS2Backend``.
# The deferred lookup is what breaks the
# ``services.subprocess_backend ↔ services.tts_backend`` cycle.
# ── GPT-SoVITS adapter (most popular voice cloning, 57k★) ──────────────────
@@ -1141,17 +973,80 @@ class SherpaOnnxBackend(TTSBackend):
# ── Registry ────────────────────────────────────────────────────────────────
_REGISTRY: dict[str, type[TTSBackend]] = {
# ── Lazy registry entry for subprocess-isolated backends ──────────────────
#
# Backends that live in their own module (to avoid an import cycle with
# ``services.subprocess_backend``) register here as ``(module_path,
# attribute_name)``. ``_REGISTRY`` resolves the entry on first access via
# the descriptor below.
_LAZY_REGISTRY: dict[str, tuple[str, str]] = {
"indextts2": ("engines.indextts", "IndexTTS2Backend"),
}
class _LazyRegistry(dict):
"""A dict that resolves selected keys via a deferred import.
Keys in ``_LAZY_REGISTRY`` are not present in ``self`` until first
access; ``__getitem__`` / ``__contains__`` / iteration all import
them on demand. Everything else behaves like a normal dict — the
registry-sandbox fixture in
``tests/backend/services/test_tts_backend_registry.py`` still gets
snapshot semantics because once a lazy key is resolved it's stored
in self exactly like a non-lazy key.
"""
def __contains__(self, key) -> bool: # noqa: D401
return dict.__contains__(self, key) or key in _LAZY_REGISTRY
def __getitem__(self, key):
if dict.__contains__(self, key):
return dict.__getitem__(self, key)
if key in _LAZY_REGISTRY:
mod_path, attr = _LAZY_REGISTRY[key]
import importlib
cls = getattr(importlib.import_module(mod_path), attr)
self[key] = cls
return cls
raise KeyError(key)
def __iter__(self):
# Yield resolved keys first, then any lazy keys that haven't been
# resolved yet. Resolving inside __iter__ would trigger a side
# effect on every list_backends() call — we keep iteration light
# and let the caller's __getitem__ trigger the import.
seen: set[str] = set()
for k in dict.__iter__(self):
seen.add(k)
yield k
for k in _LAZY_REGISTRY:
if k not in seen:
yield k
def items(self):
for k in self:
yield k, self[k]
def keys(self):
return list(iter(self))
def values(self):
return [self[k] for k in self]
_REGISTRY: dict[str, type[TTSBackend]] = _LazyRegistry({
"omnivoice": OmniVoiceBackend,
"cosyvoice": CosyVoiceBackend,
"kittentts": KittenTTSBackend,
"mlx-audio": MLXAudioBackend,
"voxcpm2": VoxCPM2Backend,
"moss-tts-nano": MossTTSNanoBackend,
"indextts2": IndexTTS2Backend,
# "indextts2": resolved lazily via _LAZY_REGISTRY -> engines.indextts
"gpt-sovits": GPTSoVITSBackend,
"sherpa-onnx": SherpaOnnxBackend,
}
})
# ── ENGINE-06 last-error cache ─────────────────────────────────────────────
@@ -1263,3 +1158,18 @@ def get_active_tts_backend(*, model=None) -> TTSBackend:
if cls is OmniVoiceBackend:
return OmniVoiceBackend(model=model)
return cls()
# ── PEP 562 lazy attribute re-export ───────────────────────────────────────
#
# Allows ``from services.tts_backend import IndexTTS2Backend`` to keep
# working even though the class itself lives in ``engines.indextts``.
# Triggers the engines.indextts import on first attribute access, which
# is after this module has finished loading — so no import cycle.
def __getattr__(name: str): # pragma: no cover - exercised via tests
if name in _LAZY_REGISTRY:
return _REGISTRY[name if name in _REGISTRY else None]
if name == "IndexTTS2Backend":
return _REGISTRY["indextts2"]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+134
View File
@@ -0,0 +1,134 @@
# OmniVoice Studio — IndexTTS-2 Engine
IndexTTS-2 (Bilibili) is OmniVoice's emotion-controlled zero-shot TTS
engine. It runs in its own subprocess + dedicated Python venv with
`transformers<5`, isolated from the OmniVoice parent process which
pins `transformers>=5.3`. This isolation is the resolution of
[#42](https://github.com/voice-design/OmniVoice/issues/42) — the
canonical `OffloadedCache` ImportError that resulted from loading
both libraries inside one Python interpreter.
## Install
IndexTTS-2 is **not** bundled with OmniVoice — the model weights are
~6 GB and the package itself pins a conflicting transformers
version. OmniVoice ships with a sidecar runner that loads IndexTTS
into an isolated venv on demand.
1. Clone the IndexTTS repo on disk:
```bash
git clone https://github.com/index-tts/index-tts.git
```
2. Install the editable package into a fresh venv. Use
`uv pip install -e .` — **never** `uv sync --all-extras`, which
would overwrite OmniVoice's lock file with `transformers<5` and
break the parent process:
```bash
cd index-tts
uv venv .venv
uv pip install -e .
```
3. Download the model weights (~6 GB). Either:
```bash
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
```
or let HuggingFace cache them on first synthesize call (the parent
forwards `HF_HOME` / `HF_HUB_CACHE` to the sidecar so the cache is
shared with the rest of OmniVoice's downloads).
4. Set the `OMNIVOICE_INDEXTTS_DIR` environment variable to the repo
root (the directory that contains `checkpoints/` and
`pyproject.toml`):
```bash
# macOS / Linux
echo 'export OMNIVOICE_INDEXTTS_DIR=$HOME/code/index-tts' >> ~/.zshrc
source ~/.zshrc
```
```powershell
# Windows PowerShell
[Environment]::SetEnvironmentVariable("OMNIVOICE_INDEXTTS_DIR","$env:USERPROFILE\code\index-tts","User")
```
5. Restart OmniVoice. IndexTTS-2 will appear in **Settings → Engines**
with `available: true` and `isolation_mode: subprocess`.
## Venv resolution order
OmniVoice probes for a usable IndexTTS Python interpreter in this
priority order (see `backend/engines/indextts/bootstrap.py`):
1. **`${OMNIVOICE_INDEXTTS_DIR}/.venv/`** — your existing clone's
venv. Highest priority, so v0.2.7 users who already ran
`uv pip install -e .` get zero migration cost on the upgrade to
v0.3.x.
2. **`backend/engines/indextts/.venv/`** — OmniVoice's own venv,
created on demand by step 3.
3. **Lazy bootstrap** — if neither venv exists, OmniVoice runs
`uv venv backend/engines/indextts/.venv` and
`uv pip install --python <python> -e ${OMNIVOICE_INDEXTTS_DIR}`
on first launch. Requires `OMNIVOICE_INDEXTTS_DIR` to be set;
raises a clear error otherwise.
The cache marker test
(`tests/backend/services/test_indextts_backward_compat.py::test_hf_home_marker_present_after_bootstrap`)
proves that the bootstrap path **never** mutates
`$HF_HOME/hub/models--IndexTeam--IndexTTS-2/` — so the 6 GB model
weights survive the upgrade byte-for-byte.
## Common errors
### `IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR ...`
You haven't pointed OmniVoice at an IndexTTS clone yet. Follow the
**Install** steps above.
### `uv is required to bootstrap the IndexTTS-2 venv but was not found on PATH`
The bootstrap path needs a working `uv` binary. Either install `uv`
into your `PATH` (https://docs.astral.sh/uv/) or pre-create the venv
manually with `uv venv` and `uv pip install -e` as in step 2.
### `IndexTTS bootstrap completed but `import indextts.infer_v2` still fails`
The clone at `OMNIVOICE_INDEXTTS_DIR` is missing the indextts
package. Verify with:
```bash
ls "$OMNIVOICE_INDEXTTS_DIR/pyproject.toml" # should exist
ls "$OMNIVOICE_INDEXTTS_DIR/indextts/" # should exist
```
If the directory is correct but the import still fails, delete
`backend/engines/indextts/.venv/` and re-launch — OmniVoice will
re-bootstrap from scratch.
## Why a subprocess?
IndexTTS-2 pins `transformers<5`. OmniVoice pins `transformers>=5.3`.
The two cannot share a Python interpreter — at import time, one of
them blows up trying to find a class the other moved or removed (the
canonical failure is `OffloadedCache` from `transformers.cache_utils`,
which v5 renamed). Running IndexTTS in its own subprocess + its own
venv lets both libraries coexist in the same OmniVoice session.
This is the structural fix for issue #42; the previous
graceful-degradation wrap (which simply detected the conflict and
disabled IndexTTS) is replaced by a real isolation primitive
(`backend/services/subprocess_backend.py::SubprocessBackend`, shipped
in Plan 02-01).
## License
IndexTTS-2 ships under a custom Bilibili research license — free for
research / non-commercial use. Commercial use requires contacting
`indexspeech@bilibili.com`. See the upstream
[README](https://github.com/index-tts/index-tts/blob/main/README.md)
for the full terms.
@@ -0,0 +1,352 @@
"""Tests for backend/engines/indextts/bootstrap.py — Plan 02-03 Task 1.
These tests cover the venv-probe priority order and the cache-reuse
contract (ENGINE-07): an existing v0.2.7 user who already cloned
IndexTTS, ran ``uv pip install -e .``, and downloaded the 6 GB model
weights MUST hit zero re-download and zero re-install when they upgrade
to v0.3.x.
We never actually invoke ``uv pip install -e`` here — that would require
network access and minutes of wall-clock per test. Instead, we
monkeypatch ``subprocess.run`` to capture the arguments, and we build
stub ``indextts`` packages on disk so the ``_venv_can_import_indextts``
probe can succeed with the system Python.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Iterator
import pytest
# tests/conftest.py prepends ./backend to sys.path.
from engines.indextts import bootstrap
# ── helpers ────────────────────────────────────────────────────────────────
def _make_fake_venv(venv_dir: Path) -> Path:
"""Create a fake venv layout with a real Python executable symlink.
Returns the path to the venv's python executable. The python is a
symlink to ``sys.executable`` so subprocess probes that invoke it
actually run real Python.
"""
if sys.platform == "win32":
bin_dir = venv_dir / "Scripts"
py_name = "python.exe"
else:
bin_dir = venv_dir / "bin"
py_name = "python"
bin_dir.mkdir(parents=True, exist_ok=True)
py_path = bin_dir / py_name
if py_path.exists():
py_path.unlink()
# Symlink avoids copying the whole interpreter binary on macOS/Linux;
# Windows test paths fall back to copyfile because symlinks require
# admin rights on some default Windows configurations.
try:
py_path.symlink_to(sys.executable)
except (OSError, NotImplementedError):
shutil.copyfile(sys.executable, py_path)
os.chmod(py_path, 0o755)
return py_path
def _install_stub_indextts(venv_dir: Path) -> None:
"""Place an importable ``indextts.infer_v2`` stub on the venv's sys.path.
The probe runs ``python -c "import indextts.infer_v2"`` — it doesn't
care about the package's contents. We just need the import to
succeed when invoked under the venv python.
Because the symlink trick above means the venv python IS the system
python, we drop the stub into the venv's ``site-packages`` directory
and set PYTHONPATH at probe time. To do that without polluting the
test's own env, we wrap the probe via PYTHONPATH directly on the
bootstrap candidate.
"""
site_packages = venv_dir / "lib" / "site-packages"
site_packages.mkdir(parents=True, exist_ok=True)
pkg = site_packages / "indextts"
pkg.mkdir(exist_ok=True)
(pkg / "__init__.py").write_text("")
(pkg / "infer_v2.py").write_text("class IndexTTS2:\n pass\n")
def _patch_probe_to_use_pythonpath(monkeypatch, venv_dir: Path) -> None:
"""Wrap ``_venv_can_import_indextts`` so it runs with PYTHONPATH set.
The venv python is a symlink to the system python (so the stub
indextts is NOT on its real site-packages). We patch the probe to
inject PYTHONPATH=<venv>/lib/site-packages so the import resolves.
"""
original = bootstrap._venv_can_import_indextts
site_packages = venv_dir / "lib" / "site-packages"
def wrapped(python_path: Path) -> bool:
# Only intercept the matching venv — other paths follow the real
# probe so we still exercise the failure code path.
if str(python_path).startswith(str(venv_dir)):
try:
proc = subprocess.run(
[str(python_path), "-c", "import indextts.infer_v2"],
capture_output=True,
timeout=10,
env={**os.environ, "PYTHONPATH": str(site_packages)},
)
return proc.returncode == 0
except Exception:
return False
return original(python_path)
monkeypatch.setattr(bootstrap, "_venv_can_import_indextts", wrapped)
@pytest.fixture(autouse=True)
def reset_bootstrap_cache() -> Iterator[None]:
"""Clear the per-process resolution cache between tests."""
bootstrap.invalidate()
yield
bootstrap.invalidate()
@pytest.fixture
def isolated_engines_venv(monkeypatch, tmp_path) -> Iterator[Path]:
"""Re-point ``_ENGINES_VENV_DIR`` to a per-test tmpdir.
Otherwise tests would race the repo's real
``backend/engines/indextts/.venv`` (which may exist on a developer
machine that already installed IndexTTS).
"""
fake_engines = tmp_path / "engines_venv"
monkeypatch.setattr(bootstrap, "_ENGINES_VENV_DIR", fake_engines)
yield fake_engines
# ── ENGINE-07: venv-probe priority order ───────────────────────────────────
def test_venv_probe_prefers_omnivoice_indextts_dir(
monkeypatch, tmp_path, isolated_engines_venv
):
"""Probe 1 wins when both Probe 1 and Probe 2 are viable.
Existing v0.2.7 users with OMNIVOICE_INDEXTTS_DIR + .venv must keep
using their venv even after upgrading to v0.3.x.
"""
omv_dir = tmp_path / "user_indextts_clone"
omv_dir.mkdir()
user_venv = omv_dir / ".venv"
py_path = _make_fake_venv(user_venv)
_install_stub_indextts(user_venv)
# Also create the engines/.venv (Probe 2) so we prove priority.
engines_venv = isolated_engines_venv
_make_fake_venv(engines_venv)
_install_stub_indextts(engines_venv)
monkeypatch.setenv("OMNIVOICE_INDEXTTS_DIR", str(omv_dir))
# Patch probe to honour our PYTHONPATH stubbing.
def wrapped(python_path: Path) -> bool:
for venv in (user_venv, engines_venv):
if str(python_path).startswith(str(venv)):
site = venv / "lib" / "site-packages"
proc = subprocess.run(
[str(python_path), "-c", "import indextts.infer_v2"],
capture_output=True,
timeout=10,
env={**os.environ, "PYTHONPATH": str(site)},
)
return proc.returncode == 0
return False
monkeypatch.setattr(bootstrap, "_venv_can_import_indextts", wrapped)
resolved = bootstrap.resolve_indextts_venv()
assert resolved == py_path, (
f"expected probe 1 ({py_path}) to win, got {resolved}"
)
def test_venv_probe_falls_back_to_engines_path(
monkeypatch, tmp_path, isolated_engines_venv
):
"""Probe 2 fires when OMNIVOICE_INDEXTTS_DIR is unset."""
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
engines_venv = isolated_engines_venv
py_path = _make_fake_venv(engines_venv)
_install_stub_indextts(engines_venv)
_patch_probe_to_use_pythonpath(monkeypatch, engines_venv)
resolved = bootstrap.resolve_indextts_venv()
assert resolved == py_path
def test_venv_probe_bootstraps_when_neither_exists(
monkeypatch, tmp_path, isolated_engines_venv
):
"""No venvs on disk + OMNIVOICE_INDEXTTS_DIR set => uv venv + uv pip install."""
omv_dir = tmp_path / "user_clone_no_venv"
omv_dir.mkdir()
monkeypatch.setenv("OMNIVOICE_INDEXTTS_DIR", str(omv_dir))
# Pretend uv is available at a stub path.
fake_uv = tmp_path / "uv-stub"
fake_uv.write_text("# fake")
fake_uv.chmod(0o755)
monkeypatch.setattr(bootstrap, "_locate_uv", lambda: str(fake_uv))
captured: list[list[str]] = []
def fake_run(cmd, **kw):
captured.append(list(cmd))
# Mimic ``uv venv`` creating the venv layout we promised.
if len(cmd) >= 2 and cmd[1] == "venv":
target = Path(cmd[2])
_make_fake_venv(target)
_install_stub_indextts(target)
# Both calls succeed.
return subprocess.CompletedProcess(cmd, 0, stdout=b"", stderr=b"")
monkeypatch.setattr(subprocess, "run", fake_run)
# Direct probe to read the stub via PYTHONPATH.
_patch_probe_to_use_pythonpath(monkeypatch, isolated_engines_venv)
resolved = bootstrap.resolve_indextts_venv()
assert resolved == bootstrap._venv_python_path(isolated_engines_venv)
# uv venv came first.
assert captured[0][:2] == [str(fake_uv), "venv"]
# Then uv pip install -e <clone>.
assert any(
c[:3] == [str(fake_uv), "pip", "install"] and str(omv_dir) in c
for c in captured
), f"expected uv pip install -e {omv_dir} in {captured}"
def test_venv_probe_raises_clear_error_when_no_install_possible(
monkeypatch, tmp_path, isolated_engines_venv
):
"""No venv on disk AND OMNIVOICE_INDEXTTS_DIR unset => raise with docs link."""
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
# Engines venv directory is the per-test tmpdir; it's empty.
with pytest.raises(RuntimeError) as excinfo:
bootstrap.resolve_indextts_venv()
message = str(excinfo.value)
assert "OMNIVOICE_INDEXTTS_DIR" in message
assert "docs/engines/indextts.md" in message
# ── ENGINE-07: cache & spawn discipline ────────────────────────────────────
def test_is_indextts_installed_no_spawn(
monkeypatch, tmp_path, isolated_engines_venv
):
"""is_indextts_installed must NOT invoke any subprocess.
The Settings UI calls list_backends() on every render — paying for a
sidecar spawn each time would deadlock the UI and break the
isolation test in the registry suite.
"""
engines_venv = isolated_engines_venv
_make_fake_venv(engines_venv)
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
# Hard-fail if anyone calls subprocess.run during is_indextts_installed.
def boom(*args, **kw):
raise AssertionError(
f"is_indextts_installed must not spawn a subprocess; got {args!r}"
)
monkeypatch.setattr(subprocess, "run", boom)
assert bootstrap.is_indextts_installed() is True
def test_is_indextts_installed_returns_false_when_no_venv(
monkeypatch, tmp_path, isolated_engines_venv
):
"""Negative path — no venv anywhere returns False without spawning."""
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
# isolated_engines_venv is an empty tmpdir; no fake venv created.
def boom(*args, **kw):
raise AssertionError("must not spawn")
monkeypatch.setattr(subprocess, "run", boom)
assert bootstrap.is_indextts_installed() is False
def test_hf_home_marker_present_after_bootstrap(
monkeypatch, tmp_path, isolated_engines_venv
):
"""HF cache is read-only from the bootstrap path. (ENGINE-07 / Pitfall 4)
The existing user's downloaded model weights at
``$HF_HOME/hub/models--IndexTeam--IndexTTS-2/`` must survive the
bootstrap byte-for-byte. We seed a marker file, run a full
resolve_indextts_venv() (Probe 1 path with stubbed indextts), and
verify the marker is untouched.
"""
hf_home = tmp_path / "hf_home"
hub_dir = hf_home / "hub" / "models--IndexTeam--IndexTTS-2"
hub_dir.mkdir(parents=True)
marker = hub_dir / "MARKER"
marker_text = "do-not-redownload-this-is-6gb"
marker.write_text(marker_text)
marker_mtime = marker.stat().st_mtime
monkeypatch.setenv("HF_HOME", str(hf_home))
# Set up a user-clone-level venv with stub indextts (Probe 1 wins).
omv_dir = tmp_path / "user_indextts_clone"
omv_dir.mkdir()
user_venv = omv_dir / ".venv"
_make_fake_venv(user_venv)
_install_stub_indextts(user_venv)
monkeypatch.setenv("OMNIVOICE_INDEXTTS_DIR", str(omv_dir))
_patch_probe_to_use_pythonpath(monkeypatch, user_venv)
# Both queries must complete with the cache marker untouched.
assert bootstrap.is_indextts_installed() is True
bootstrap.resolve_indextts_venv()
assert marker.read_text() == marker_text
assert marker.stat().st_mtime == marker_mtime
def test_resolve_caches_result(monkeypatch, tmp_path, isolated_engines_venv):
"""Second call returns the cached path without re-probing."""
omv_dir = tmp_path / "user_clone"
omv_dir.mkdir()
user_venv = omv_dir / ".venv"
_make_fake_venv(user_venv)
_install_stub_indextts(user_venv)
monkeypatch.setenv("OMNIVOICE_INDEXTTS_DIR", str(omv_dir))
_patch_probe_to_use_pythonpath(monkeypatch, user_venv)
first = bootstrap.resolve_indextts_venv()
# Replace the probe with a sentinel that would fail if called.
sentinel_called = {"count": 0}
def sentinel(_path):
sentinel_called["count"] += 1
return False
monkeypatch.setattr(bootstrap, "_venv_can_import_indextts", sentinel)
second = bootstrap.resolve_indextts_venv()
assert first == second
assert sentinel_called["count"] == 0, (
"second resolve_indextts_venv() call probed the venv again instead "
"of using the cache"
)
@@ -0,0 +1,438 @@
"""Tests for IndexTTS2Backend on the SubprocessBackend primitive — Plan 02-03.
This is the headline test file for issue #42's closure:
``test_coexist_with_omnivoice_in_one_session`` proves that the
in-process OmniVoiceBackend (which imports transformers>=5.3) and the
subprocess-isolated IndexTTS2Backend can both serve generate() from the
SAME Python session. The two transformers versions can no longer
collide because IndexTTS runs in a different OS process.
Real IndexTTS-2 model load is ~20 s cold and depends on a ~6 GB model
download — these tests use a mock sidecar fixture
(``tests/fixtures/mock_indextts_sidecar.py``) that mimics the
production wire protocol without importing the indextts library.
"""
from __future__ import annotations
import json
import struct
import sys
from pathlib import Path
from typing import Iterator
import psutil
import pytest
import torch
# tests/conftest.py prepends ./backend to sys.path.
from engines.indextts import bootstrap as indextts_bootstrap
from services import tts_backend
from services.subprocess_backend import SubprocessBackend, _read_exact
from services.tts_backend import IndexTTS2Backend, OmniVoiceBackend, list_backends
REPO_ROOT = Path(__file__).resolve().parents[3]
MOCK_SIDECAR = REPO_ROOT / "tests" / "fixtures" / "mock_indextts_sidecar.py"
# ── fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _reset_bootstrap_cache():
indextts_bootstrap.invalidate()
yield
indextts_bootstrap.invalidate()
@pytest.fixture
def patched_indextts_backend(monkeypatch) -> Iterator[IndexTTS2Backend]:
"""An IndexTTS2Backend that spawns the MOCK sidecar under sys.executable.
Overrides ``venv_python``, ``sidecar_script``, and ``is_available``
via classmethod patches so we can construct a real instance, hit
every code path of ``generate()``, and assert the wire protocol
without paying the cost of the real model.
"""
monkeypatch.setattr(
IndexTTS2Backend, "venv_python",
classmethod(lambda cls: Path(sys.executable)),
)
monkeypatch.setattr(
IndexTTS2Backend, "sidecar_script",
classmethod(lambda cls: MOCK_SIDECAR),
)
monkeypatch.setattr(
IndexTTS2Backend, "is_available",
classmethod(lambda cls: (True, "ok (mocked)")),
)
backend = IndexTTS2Backend()
yield backend
try:
backend.shutdown()
except Exception:
pass
# ── unit / structural ─────────────────────────────────────────────────────
def test_indextts2backend_is_subprocess_subclass():
"""The new IndexTTS2Backend must be a SubprocessBackend subclass."""
assert issubclass(IndexTTS2Backend, SubprocessBackend)
def test_indextts2backend_has_subprocess_marker():
"""The duck-typed marker for list_backends's isolation_mode detection."""
assert getattr(IndexTTS2Backend, "_is_subprocess_isolated", False) is True
def test_indextts2backend_class_methods_present():
"""The subclass contract — venv_python, sidecar_script, is_available."""
assert hasattr(IndexTTS2Backend, "venv_python")
assert hasattr(IndexTTS2Backend, "sidecar_script")
assert hasattr(IndexTTS2Backend, "is_available")
def test_old_inprocess_state_removed():
"""The legacy ``_model`` instance attribute is gone.
The old IndexTTS2Backend held an in-process IndexTTS2 instance on
``self._model``. The new shape stores no model — that lives in the
sidecar. This assertion catches accidental re-introduction of the
old shape if a future refactor copy-pastes the legacy code back.
"""
# We can't construct without spawn (is_available defaults to checking
# the real venv), so introspect class dict directly.
assert "_model" not in IndexTTS2Backend.__dict__
# ── is_available — no spawn discipline ────────────────────────────────────
def test_is_available_no_spawn(monkeypatch):
"""is_available must not spawn the sidecar even when the venv exists.
Settings UI calls list_backends() on every render. If is_available
paid for a sidecar spawn each time, the picker would deadlock for
20+ seconds during the IndexTTS cold load.
"""
monkeypatch.setattr(
indextts_bootstrap, "is_indextts_installed", lambda: True,
)
me = psutil.Process()
before = set(c.pid for c in me.children(recursive=True))
ok, msg = IndexTTS2Backend.is_available()
after = set(c.pid for c in me.children(recursive=True))
assert ok, f"expected is_available True when venv exists, got {msg}"
assert msg == "ok"
assert after == before, (
f"is_available() spawned children: new pids = {after - before}"
)
def test_is_available_no_venv(monkeypatch):
"""No venv => clear actionable error with the install-docs path."""
monkeypatch.setattr(
indextts_bootstrap, "is_indextts_installed", lambda: False,
)
ok, msg = IndexTTS2Backend.is_available()
assert ok is False
assert "OMNIVOICE_INDEXTTS_DIR" in msg
assert "docs/engines/indextts.md" in msg
# ── registry integration ──────────────────────────────────────────────────
def test_list_backends_includes_indextts_with_subprocess_isolation_mode():
"""list_backends() reports indextts2 with isolation_mode='subprocess'."""
entries = {e["id"]: e for e in list_backends()}
assert "indextts2" in entries
assert entries["indextts2"]["isolation_mode"] == "subprocess"
# display_name was preserved verbatim from the legacy class.
assert entries["indextts2"]["display_name"] == (
"IndexTTS2 (emotion control, duration control, zero-shot)"
)
# ── round-trip via the mock sidecar ───────────────────────────────────────
def test_synthesize_via_mocked_sidecar(patched_indextts_backend):
"""Spawn → synthesize → expect 1 s of non-zero audio at 24 kHz float32."""
audio = patched_indextts_backend.generate(
"hello world",
ref_audio="/tmp/fake_ref.wav",
)
assert isinstance(audio, torch.Tensor)
assert audio.shape == (1, 24000), f"got shape {tuple(audio.shape)}"
assert audio.dtype == torch.float32
# 0.5-amp sine wave → max abs > 0.3 after int16 round-trip.
assert torch.max(torch.abs(audio)).item() > 0.3
def test_synthesize_forwards_emotion_kwargs_via_vector(monkeypatch, patched_indextts_backend):
"""emo_vector wins; duration converts to target_tokens (~21 Hz).
The parent-side arbitration lives in ``IndexTTS2Backend.generate``.
We intercept ``_send`` to capture the JSON payload after the parent
finished building it; the sidecar still completes the round-trip so
``generate`` returns a real tensor.
"""
backend = patched_indextts_backend
sent: list[dict] = []
original_send = SubprocessBackend._send
def spy_send(self, msg):
if msg.get("op") == "synthesize":
sent.append(dict(msg))
return original_send(self, msg)
monkeypatch.setattr(SubprocessBackend, "_send", spy_send)
audio = backend.generate(
"hi",
ref_audio="/tmp/ref.wav",
emo_vector=[1, 0, 0, 0, 0, 0, 0, 0],
emo_audio="/tmp/should_be_ignored.wav",
emo_text="should_be_ignored",
emo_alpha=0.9, # ignored when vector wins
use_random=True,
duration=2.0,
)
assert audio.shape == (1, 24000)
assert len(sent) == 1
payload = sent[0]
assert payload["op"] == "synthesize"
assert payload["text"] == "hi"
assert payload["ref_audio"] == "/tmp/ref.wav"
assert payload["emo_vector"] == [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
assert payload["use_random"] is True
# duration=2.0 → target_tokens = int(2.0 * 21) = 42
assert payload["target_tokens"] == 42
# The losing modalities are dropped — sidecar never sees them.
assert "emo_audio_prompt" not in payload
assert "emo_text" not in payload
def test_synthesize_forwards_emotion_kwargs_via_text(monkeypatch, patched_indextts_backend):
"""emo_text path caps emo_alpha to ≤0.6 (IndexTTS recommendation)."""
backend = patched_indextts_backend
sent: list[dict] = []
original_send = SubprocessBackend._send
def spy_send(self, msg):
if msg.get("op") == "synthesize":
sent.append(dict(msg))
return original_send(self, msg)
monkeypatch.setattr(SubprocessBackend, "_send", spy_send)
backend.generate(
"hi",
ref_audio="/tmp/ref.wav",
emo_text="terrified and panicking",
emo_alpha=0.95, # must be capped to 0.6
use_random=True,
)
assert len(sent) == 1
p = sent[0]
assert p["emo_text"] == "terrified and panicking"
assert p["use_emo_text"] is True
assert p["emo_alpha"] == 0.6
assert p["use_random"] is True
def test_synthesize_forwards_emotion_kwargs_via_audio(monkeypatch, patched_indextts_backend):
"""emo_audio path forwards emo_audio_prompt + emo_alpha verbatim."""
backend = patched_indextts_backend
sent: list[dict] = []
original_send = SubprocessBackend._send
def spy_send(self, msg):
if msg.get("op") == "synthesize":
sent.append(dict(msg))
return original_send(self, msg)
monkeypatch.setattr(SubprocessBackend, "_send", spy_send)
backend.generate(
"hi",
ref_audio="/tmp/ref.wav",
emo_audio="/tmp/emo_ref.wav",
emo_alpha=0.85,
)
assert len(sent) == 1
p = sent[0]
assert p["emo_audio_prompt"] == "/tmp/emo_ref.wav"
assert p["emo_alpha"] == 0.85
# The text & vector paths are NOT sent when audio wins.
assert "emo_vector" not in p
assert "emo_text" not in p
def test_description_falls_through_to_emo_text(monkeypatch, patched_indextts_backend):
"""OpenAI-compat description= maps to emo_text when no other modality set."""
backend = patched_indextts_backend
sent: list[dict] = []
original_send = SubprocessBackend._send
def spy_send(self, msg):
if msg.get("op") == "synthesize":
sent.append(dict(msg))
return original_send(self, msg)
monkeypatch.setattr(SubprocessBackend, "_send", spy_send)
backend.generate(
"hi",
ref_audio="/tmp/ref.wav",
description="warm and confident female voice",
)
assert len(sent) == 1
p = sent[0]
assert p["emo_text"] == "warm and confident female voice"
assert p["use_emo_text"] is True
def test_generate_requires_ref_audio(patched_indextts_backend):
"""IndexTTS2 cannot voice-clone without a reference; the parent rejects
early so the sidecar isn't woken up on a no-op."""
with pytest.raises(RuntimeError, match="reference audio"):
patched_indextts_backend.generate("hello", ref_audio=None)
# ── #42 closure: in-process OmniVoice + subprocess IndexTTS coexist ───────
def test_coexist_with_omnivoice_in_one_session(monkeypatch, patched_indextts_backend):
"""The headline #42 closure test.
OmniVoiceBackend.is_available() succeeds in this interpreter (it
imports omnivoice.models.omnivoice with transformers>=5.3) AND the
subprocess-isolated IndexTTS2Backend serves a generate() in the
same Python process. Before Plan 02-03, the second engine couldn't
coexist because IndexTTS demanded transformers<5 at import time.
Now IndexTTS lives in a separate interpreter (the mock sidecar
here, the real venv in production) so the two transformers
versions never see each other.
"""
# OmniVoice imports its real package — if that succeeds, the
# in-process side is fine. We don't actually generate (would
# require model weights), but reaching is_available() proves the
# transformers>=5.3 import is intact.
ok, msg = OmniVoiceBackend.is_available()
if not ok:
# In CI / minimal install the omnivoice package may not be
# importable; the test should not fail for that — what we care
# about is that calling IndexTTS doesn't BREAK the OmniVoice
# import. We re-call is_available() AFTER the IndexTTS generate
# and assert the message is identical (no new breakage).
pre = msg
else:
pre = "ready"
audio = patched_indextts_backend.generate(
"hello from indextts",
ref_audio="/tmp/ref.wav",
)
assert audio.shape == (1, 24000)
ok2, msg2 = OmniVoiceBackend.is_available()
# Whatever state OmniVoiceBackend was in before, it's the same
# after IndexTTS ran — no new import errors, no new AttributeErrors.
assert ok2 == ok, (
f"OmniVoice availability changed after IndexTTS generate "
f"({ok}->{ok2}, msg={msg2})"
)
# ── env forwarding (D5, verified for IndexTTS specifically) ───────────────
def test_env_forwarding_to_indextts_sidecar(monkeypatch, tmp_path):
"""HF_TOKEN / HF_HOME / HF_ENDPOINT / HF_HUB_CACHE reach the sidecar."""
monkeypatch.setenv("HF_TOKEN", "hf_indextts_test")
monkeypatch.setenv("HF_HOME", str(tmp_path / "hf_home"))
monkeypatch.setenv("HF_ENDPOINT", "https://mirror.example")
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path / "hf_cache"))
monkeypatch.setattr(
IndexTTS2Backend, "venv_python",
classmethod(lambda cls: Path(sys.executable)),
)
monkeypatch.setattr(
IndexTTS2Backend, "sidecar_script",
classmethod(lambda cls: MOCK_SIDECAR),
)
monkeypatch.setattr(
IndexTTS2Backend, "is_available",
classmethod(lambda cls: (True, "ok")),
)
backend = IndexTTS2Backend()
try:
with backend._lock:
backend._spawn()
backend._send({"op": "probe_env"})
# probe_env_result is intentionally NOT in PARENT_INBOUND_OPS — it's
# a test-only op. Read the raw frame off stdout.
proc = backend._proc
assert proc is not None
header = _read_exact(proc.stdout, 4)
assert header is not None
(n,) = struct.unpack("!I", header)
body = _read_exact(proc.stdout, n)
assert body is not None
reply = json.loads(body.decode("utf-8"))
assert reply["op"] == "probe_env_result"
keys = reply["keys"]
assert keys["HF_TOKEN"] == "hf_indextts_test"
assert keys["HF_HOME"] == str(tmp_path / "hf_home")
assert keys["HF_ENDPOINT"] == "https://mirror.example"
assert keys["HF_HUB_CACHE"] == str(tmp_path / "hf_cache")
finally:
backend.shutdown()
# ── source-level invariants ───────────────────────────────────────────────
def test_no_indextts_import_in_tts_backend():
"""tts_backend.py must not import the indextts library at module level.
The whole point of the migration is that the parent's transformers>=5.3
cannot coexist with IndexTTS's transformers<5 in one interpreter.
Anything that triggers an `import indextts` at module load time
re-opens #42.
"""
src = (REPO_ROOT / "backend" / "services" / "tts_backend.py").read_text()
# Allow comments / docstring mentions of the package name; reject
# actual import statements at module scope.
for bad in ("from indextts", "import indextts"):
# Tolerate the string appearing inside a string-quoted comment of a
# docstring; the literal import statement should not appear at all.
for line in src.splitlines():
stripped = line.strip()
if stripped.startswith(bad):
pytest.fail(
f"forbidden top-level import in tts_backend.py: {stripped!r}\n"
"IndexTTS imports MUST live inside backend/engines/indextts/main.py "
"(the sidecar) only — see Plan 02-03 / issue #42."
)
def test_sidecar_imports_indextts():
"""backend/engines/indextts/main.py imports indextts (lazy, inside fn)."""
src = (REPO_ROOT / "backend" / "engines" / "indextts" / "main.py").read_text()
assert "from indextts.infer_v2 import IndexTTS2" in src, (
"sidecar must import IndexTTS2 from indextts.infer_v2 (it's the "
"real model loader). See bootstrap.py for venv resolution."
)
+155
View File
@@ -0,0 +1,155 @@
"""Mock IndexTTS sidecar — test fixture for Plan 02-03 Task 2.
Mimics ``backend/engines/indextts/main.py`` without importing the
indextts library. Used by ``tests/backend/services/test_indextts_sidecar.py``
to exercise the SubprocessBackend round-trip and the coexistence-with-
OmniVoice integration test without paying the 6 GB / 20 s cost of
loading the real IndexTTS-2 model.
Wire protocol: length-prefixed JSON over stdin/stdout, identical to the
production sidecar. Stdlib only — runs under the system Python that the
test harness uses.
Op flow (subset of production):
1. emit {"op": "ready", "engine": "indextts2", "sample_rate": 24000}
2. parent → {"op": "ping"} → reply {"op": "pong"}
3. parent → {"op": "synthesize", "text": "...", "ref_audio": "...",
...} → emit {"op": "audio", "audio_pcm_b64": <1 s of
0.5-amplitude sine wave>, "sample_rate": 24000,
"n_samples": 24000, "forwarded_kwargs": {...}}
4. parent → {"op": "shutdown"} → exit 0
5. parent → {"op": "probe_env"} (test-only) → emit
{"op": "probe_env_result", "keys": {HF_TOKEN, HF_HOME,
HF_ENDPOINT, HF_HUB_CACHE}}
The audio frame includes ``forwarded_kwargs`` so the parent test can
assert that emotion/duration kwargs survived the JSON round-trip.
``probe_env_result`` is NOT in the parent's PARENT_INBOUND_OPS
allowlist by design — it has to be read off the raw pipe (the
test does that).
"""
from __future__ import annotations
import base64
import json
import math
import os
import struct
import sys
import traceback
MAX_FRAME_BYTES = 64 * 1024 * 1024
SAMPLE_RATE = 24000
def _send(stream, obj):
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _sine_pcm_b64(sample_rate: int = SAMPLE_RATE) -> tuple[str, int]:
"""Return base64-encoded 1 s of 0.5-amplitude 440 Hz sine, int16 PCM.
Amplitude 0.5 → ``abs(tensor).max() > 0.3`` assertion in the test.
No numpy dependency — keeps the mock sidecar stdlib-only.
"""
n = int(sample_rate)
amp = 0.5
freq = 440.0
two_pi_f_over_sr = 2.0 * math.pi * freq / sample_rate
out = bytearray()
for i in range(n):
v = amp * math.sin(two_pi_f_over_sr * i)
s16 = max(-32768, min(32767, int(v * 32767.0)))
out += struct.pack("<h", s16)
return base64.b64encode(bytes(out)).decode("ascii"), n
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
_send(stdout, {
"op": "ready",
"engine": "indextts2",
"sample_rate": SAMPLE_RATE,
})
while True:
try:
msg = _recv(stdin)
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op") if isinstance(msg, dict) else None
try:
if op == "ping":
_send(stdout, {"op": "pong"})
elif op == "synthesize":
pcm_b64, n_samples = _sine_pcm_b64(SAMPLE_RATE)
# Echo back the kwargs so the test can assert they were
# forwarded through the parent's emotion arbitration.
forwarded = {k: v for k, v in msg.items() if k != "op"}
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": SAMPLE_RATE,
"n_samples": n_samples,
"forwarded_kwargs": forwarded,
})
elif op == "shutdown":
return 0
elif op == "probe_env":
_send(stdout, {
"op": "probe_env_result",
"keys": {
"HF_TOKEN": os.environ.get("HF_TOKEN"),
"HF_HOME": os.environ.get("HF_HOME"),
"HF_ENDPOINT": os.environ.get("HF_ENDPOINT"),
"HF_HUB_CACHE": os.environ.get("HF_HUB_CACHE"),
},
})
else:
_send(stdout, {
"op": "error",
"stage": "dispatch",
"message": f"unknown op: {op!r}",
})
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
+40 -20
View File
@@ -114,35 +114,55 @@ def test_indextts_is_available_returns_tuple():
def test_indextts_unavailable_message_is_actionable():
"""When IndexTTS is not installed, the message should guide the user."""
"""When IndexTTS is not installed, the message should guide the user.
Plan 02-03 migrated IndexTTS to a subprocess + dedicated venv (closes
#42 properly). The unavailable message is no longer about the
transformers conflict — it's about the venv not existing — and it
must point the user at the install docs.
"""
ok, msg = tts_backend.IndexTTS2Backend.is_available()
if not ok:
# Must mention install method
assert "uv pip install" in msg or "git clone" in msg or "conflict" in msg.lower()
# Must point at the env-var-driven install path OR the docs.
msg_lower = msg.lower()
assert (
"omnivoice_indextts_dir" in msg_lower
or "docs/engines/indextts.md" in msg_lower
or "uv pip install" in msg_lower
or "git clone" in msg_lower
or "conflict" in msg_lower
), f"is_available() failure message not actionable: {msg!r}"
def test_indextts_catches_transformers_conflict():
"""Simulate the transformers version conflict ImportError."""
with mock.patch.dict("sys.modules", {"indextts": None, "indextts.infer_v2": None}):
# Force a fresh call — the mock makes import raise ImportError
ok, msg = tts_backend.IndexTTS2Backend.is_available()
assert ok is False
assert isinstance(msg, str)
def test_indextts_no_inprocess_import_attempted():
"""The new IndexTTS2Backend must NOT attempt `import indextts` at any point.
Plan 02-03 closes #42 by running IndexTTS in a subprocess with its
own venv — the parent's transformers>=5.3 never touches
transformers<5. We assert by patching builtins.__import__: if
is_available() triggers an indextts.* import, the assertion fires.
"""
calls: list[str] = []
original_import = (
__builtins__.__import__
if hasattr(__builtins__, "__import__")
else __import__
)
def test_indextts_catches_transformers_keyword_in_error():
"""When the ImportError mentions 'transformers', the message should explain the conflict."""
original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__
def mock_import(name, *args, **kwargs):
if name == "indextts.infer_v2":
raise ImportError("cannot import name 'OffloadedCache' from 'transformers.cache_utils'")
def tracking_import(name, *args, **kwargs):
if name.startswith("indextts"):
calls.append(name)
return original_import(name, *args, **kwargs)
with mock.patch("builtins.__import__", side_effect=mock_import):
with mock.patch("builtins.__import__", side_effect=tracking_import):
ok, msg = tts_backend.IndexTTS2Backend.is_available()
assert ok is False
assert "conflict" in msg.lower() or "transformers" in msg.lower()
assert calls == [], (
f"IndexTTS2Backend.is_available() must not import indextts.* in the "
f"parent process (Plan 02-03 / #42); got imports: {calls}"
)
assert isinstance(ok, bool)
assert isinstance(msg, str)
def test_indextts_docstring_warns_about_uv_sync():