Files
mergetestandClaude Opus 4.8 3777d3a62c feat(tts): add MOSS-TTS-v1.5 (8B) and dots.tts (2B) as opt-in engines (#498)
Adds two zero-shot voice-cloning TTS engines requested in #498, both
opt-in and subprocess-isolated with their own dedicated venv — the same
pattern as IndexTTS-2. The dedicated venv is forced, not just chosen:
each upstream pins a transformers version that conflicts with the
parent's >=5.3 (MOSS-TTS-v1.5 ==5.0.0, dots.tts ==4.57.0), so they cannot
share the parent interpreter.

Because they use the clone+venv bootstrap (env var -> clone -> uv venv),
this touches no pyproject.toml / uv.lock / bun.lock — `uv sync
--all-extras` and Docker's `bun install --frozen-lockfile` are unchanged,
so main's CI/Docker matrix stays green.

Engines:
- moss-tts-v15: 8B, 31 langs, ~16 GB weights, 24 kHz. AutoModel/
  AutoProcessor via trust_remote_code. gpu_compat=(cuda,cpu) — MPS is
  undocumented/untested upstream so it is never claimed; on a Mac it runs
  on CPU. Apache-2.0, no license gate.
- dots-tts: 2B, 24 langs, ~9 GB weights, 48 kHz. DotsTtsRuntime;
  continuation cloning (prompt_audio_path+prompt_text). Upstream is
  Linux/macOS-only, so is_available() gates it off cleanly on Windows
  (cross-platform parity rule — it is opt-in, never a broken default).

Wiring: registered in _LAZY_REGISTRY + _INSTALL_HINTS. list_backends()
surfaces both as subprocess/[cuda,cpu]/available-until-installed; the
data-driven Settings engine picker needs no frontend change.

Tests (19, fail-before/pass-after): registry resolution, subprocess
marker, no-MPS gpu_compat, the Windows gate, not-installed honesty, and
the parent-side generate() kwarg arbitration. Existing engine suite still
55 passed / 5 skipped. Sidecar inference follows the upstream-documented
APIs but, like IndexTTS/Supertonic, can't be executed in CI without the
multi-GB model clones.

Docs (same-PR per docs-sync rule): README + README_CN engine tables, new
docs/engines/moss-tts-v15.md + dots-tts.md, disk-usage.md (torch-dedup
note), CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 00:02:27 +05:30

164 lines
5.9 KiB
Python

"""Tests for the dots.tts engine (issue #498).
dots.tts runs in a dedicated subprocess venv (transformers==4.57.0), so
these tests never import dots.tts itself. They exercise the parent-side
wiring that ships in the default install: registry resolution, subprocess
isolation, the Windows-unsupported gate (cross-platform parity rule),
hardware honesty, and the generate() kwarg arbitration. No network, no
optional deps, no subprocess spawn.
"""
from __future__ import annotations
import importlib
import sys
import pytest
# ── registry wiring ────────────────────────────────────────────────────────
def test_registry_contains_dots_tts():
from services.tts_backend import _REGISTRY, get_backend_class
assert "dots-tts" in _REGISTRY, (
"_REGISTRY is missing 'dots-tts'; check _LAZY_REGISTRY in "
"services/tts_backend.py"
)
cls = _REGISTRY["dots-tts"]
assert cls.__name__ == "DotsTTSBackend"
assert get_backend_class("dots-tts") is cls
assert getattr(cls, "_is_subprocess_isolated", False), (
"DotsTTSBackend should be subprocess-isolated"
)
for name in ("is_available", "generate", "sample_rate", "supported_languages"):
assert hasattr(cls, name), f"DotsTTSBackend missing {name!r}"
def test_pep562_lazy_import():
mod = importlib.import_module("services.tts_backend")
cls = mod._REGISTRY["dots-tts"]
assert cls.__name__ == "DotsTTSBackend"
def test_install_hint_present():
from services.tts_backend import _INSTALL_HINTS
hint = _INSTALL_HINTS.get("dots-tts", "")
assert "OMNIVOICE_DOTS_TTS_DIR" in hint
assert "rednote-hilab" in hint
def test_sidecar_script_ships():
from engines.dots_tts.bootstrap import DOTS_TTS_SIDECAR_SCRIPT
assert DOTS_TTS_SIDECAR_SCRIPT.name == "main.py"
assert DOTS_TTS_SIDECAR_SCRIPT.is_file()
# ── hardware + platform honesty ────────────────────────────────────────────
def test_gpu_compat_cuda_cpu_no_mps():
from engines.dots_tts import DotsTTSBackend
assert DotsTTSBackend.gpu_compat == ("cuda", "cpu")
def test_sample_rate_is_48k():
from engines.dots_tts import DotsTTSBackend
b = DotsTTSBackend()
assert b.sample_rate == 48000
assert b.supported_languages == ["multi"]
def test_windows_is_gated_off(monkeypatch):
"""Cross-platform rule: dots.tts upstream is Linux/macOS-only, so on
Windows is_available() must refuse cleanly (not advertise a dead engine)."""
monkeypatch.setattr(sys, "platform", "win32")
from engines.dots_tts import DotsTTSBackend
ok, msg = DotsTTSBackend.is_available()
assert ok is False
assert "Windows" in msg
assert "mps" not in msg.lower()
def test_is_available_not_installed_is_honest(monkeypatch):
"""On a supported OS without the venv, gate cleanly with an actionable
hint and never claim MPS."""
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(
"engines.dots_tts.bootstrap.is_dots_tts_installed",
lambda: False,
)
from engines.dots_tts import DotsTTSBackend
ok, msg = DotsTTSBackend.is_available()
assert ok is False
assert "OMNIVOICE_DOTS_TTS_DIR" in msg
assert "docs/engines/dots-tts.md" in msg # actionable pointer
# No-MPS honesty is enforced by gpu_compat; the message may disclaim MPS.
# ── generate() parent-side arbitration ─────────────────────────────────────
def test_clone_with_transcript_and_overrides(monkeypatch):
"""ref_audio+ref_text → continuation cloning; num_step/guidance forwarded."""
captured: dict = {}
def fake_super_generate(self, text, **kw):
import torch
captured["text"] = text
captured.update(kw)
return torch.zeros(1, 8)
from engines.dots_tts import DotsTTSBackend
# Patch the exact SubprocessBackend in this backend's MRO (not via a
# module-path string): survives the sys.modules['services.*'] reloads
# other tests perform, so super().generate() hits the fake instead of
# dispatching to the real class and trying to spawn a sidecar. (#498)
_sub = next(c for c in DotsTTSBackend.__mro__ if c.__name__ == "SubprocessBackend")
monkeypatch.setattr(_sub, "generate", fake_super_generate)
DotsTTSBackend().generate(
"speak this",
ref_audio="/tmp/ref.wav",
ref_text="the reference transcript",
language="en",
num_step=20,
guidance_scale=1.5,
)
assert captured["ref_audio"] == "/tmp/ref.wav"
assert captured["ref_text"] == "the reference transcript"
assert captured["language"] == "en"
assert captured["num_steps"] == 20
assert captured["guidance_scale"] == 1.5
def test_orphan_ref_text_dropped_and_dots_defaults(monkeypatch):
"""ref_text without ref_audio is dropped (upstream would raise), and the
dots-appropriate defaults (num_steps=10, guidance=1.2) apply."""
captured: dict = {}
def fake_super_generate(self, text, **kw):
import torch
captured.update(kw)
return torch.zeros(1, 8)
from engines.dots_tts import DotsTTSBackend
# Patch the exact SubprocessBackend in this backend's MRO (not via a
# module-path string): survives the sys.modules['services.*'] reloads
# other tests perform, so super().generate() hits the fake instead of
# dispatching to the real class and trying to spawn a sidecar. (#498)
_sub = next(c for c in DotsTTSBackend.__mro__ if c.__name__ == "SubprocessBackend")
monkeypatch.setattr(_sub, "generate", fake_super_generate)
DotsTTSBackend().generate("no reference", ref_text="orphan transcript")
assert "ref_text" not in captured
assert "ref_audio" not in captured
assert captured["num_steps"] == 10
assert captured["guidance_scale"] == 1.2