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>
164 lines
5.9 KiB
Python
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
|