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:
co-authored by
Claude Opus 4.7
parent
0fc5ea6cf3
commit
c3695e1668
+163
@@ -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 |
|
||||
@@ -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.0–1.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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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.0–1.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}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user