Files
VoiceStudio/backend/engines/moss_tts_v15/__init__.py
T
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

195 lines
8.6 KiB
Python

"""MOSS-TTS-v1.5 sidecar package (issue #498).
MOSS-TTS-v1.5 is OpenMOSS's 8B flagship TTS — a Qwen3-8B language backbone
plus a 1.6B audio codec, 31 languages, zero-shot voice cloning, token-level
duration control and inline ``[pause Ns]`` markers. Apache-2.0.
It runs in its own subprocess **and its own venv**, isolated from the
OmniVoice parent process, for the *same* reason IndexTTS does: a hard
``transformers`` version conflict. MOSS-TTS-v1.5's ``torch-runtime`` extra
pins ``transformers==5.0.0`` (verified against the upstream
``pyproject.toml``), while OmniVoice pins ``transformers>=5.3.0``. The two
cannot share one interpreter — so MOSS lives behind ``SubprocessBackend``
with a dedicated venv, exactly like ``engines.indextts``.
Three public entry points live in this package:
* ``MossTTSV15Backend`` (this module) — the SubprocessBackend subclass
that ``services.tts_backend._LAZY_REGISTRY`` resolves on first access.
Defined HERE (not in ``services.tts_backend``) to break the import
cycle: ``services.subprocess_backend`` imports ``TTSBackend`` from
``services.tts_backend``, so the backend class must live downstream of
that module finishing its import. Same indirection as IndexTTS /
Supertonic-3.
* ``main.py`` — the sidecar entrypoint (runs under MOSS's venv with
``transformers==5.0.0``; never imported by the parent).
* ``bootstrap.py`` — the venv-probe + lazy-bootstrap helper.
Do NOT import ``main.py`` from the parent process — it runs under a
different venv (``transformers==5.0.0``) and importing it in-process would
re-introduce the exact conflict this isolation exists to avoid.
Hardware honesty (cross-platform rule): MOSS-TTS-v1.5's upstream documents
only CUDA and CPU. There is **no documented or tested MPS path** — the
custom ``trust_remote_code`` modelling code and the separate audio
tokenizer are unverified on Apple Silicon. We therefore advertise
``gpu_compat = ("cuda", "cpu")`` and the sidecar selects ``cuda`` when
present else ``cpu`` — it never silently routes to MPS where it might
crash. On Apple Silicon the engine honestly resolves to CPU (slow but
correct), and the engine is opt-in regardless, so it never becomes a
broken default on any platform.
"""
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.moss_tts_v15")
#: 1 second of audio ≈ 12.5 codec tokens (MOSS-TTS-v1.5 model card). Used to
#: translate OmniVoice's ``duration`` (seconds) into the model's ``tokens``
#: duration-control argument.
TOKENS_PER_SECOND: float = 12.5
class MossTTSV15Backend(SubprocessBackend):
"""MOSS-TTS-v1.5 (OpenMOSS) — 8B, 31 langs, zero-shot clone, CUDA/CPU.
Runs in a long-lived sidecar over length-prefixed JSON-over-stdio in a
dedicated venv (``transformers==5.0.0``). The first synthesize cold-loads
~16 GB of bf16 weights (CUDA) / fp32 (CPU); subsequent calls reuse the
process and the in-memory model.
Installation (transparent to power users who already cloned MOSS-TTS —
OmniVoice prefers their existing ``${DIR}/.venv``)::
git clone https://github.com/OpenMOSS/MOSS-TTS.git
cd MOSS-TTS
# CUDA host:
uv venv && uv pip install -e ".[torch-runtime]"
# non-CUDA host (CPU): install plain torch/transformers instead of +cu128
Set ``OMNIVOICE_MOSS_TTS_V15_DIR`` to the clone root. OmniVoice creates
``backend/engines/moss_tts_v15/.venv`` lazily on first launch if no venv
exists yet (CUDA hosts only — the upstream ``torch-runtime`` extra is
``+cu128``); the user's existing ``${DIR}/.venv`` is preferred if
present, so no re-install is needed.
License: Apache-2.0 (code + weights) — no acceptance gate needed.
"""
id = "moss-tts-v15"
display_name = (
"MOSS-TTS-v1.5 (8B, 31 langs, zero-shot clone, CUDA/CPU, Apache-2.0)"
)
supports_voice_design = False # requires ref audio for timbre cloning
_DEFAULT_SAMPLE_RATE = 24000
# Honest hardware surface: upstream documents CUDA + CPU only. MPS is
# undocumented / untested, so we do NOT claim it (cross-platform rule).
gpu_compat = ("cuda", "cpu")
# ── availability ───────────────────────────────────────────────────────
@classmethod
def is_available(cls) -> tuple[bool, str]:
# IMPORTANT: do NOT attempt to import MOSS / its transformers==5.0.0
# here. The parent pins transformers>=5.3 — co-importing the two in
# one interpreter is exactly the conflict this subprocess isolation
# exists to avoid. We only verify the venv exists on disk; a real
# health-check (spawn + ping) is gated on the user's "Test engine"
# action in Settings, same as IndexTTS.
from engines.moss_tts_v15.bootstrap import (
MOSS_TTS_V15_SIDECAR_SCRIPT,
is_moss_tts_v15_installed,
)
if not is_moss_tts_v15_installed():
return False, (
"MOSS-TTS-v1.5 venv not found. Set OMNIVOICE_MOSS_TTS_V15_DIR "
"to your MOSS-TTS clone (the directory containing pyproject.toml) "
"and restart OmniVoice. CUDA or CPU only (no MPS). See "
"docs/engines/moss-tts-v15.md for the full install walk-through."
)
if not MOSS_TTS_V15_SIDECAR_SCRIPT.exists():
return False, (
"MOSS-TTS-v1.5 sidecar script missing at "
f"{MOSS_TTS_V15_SIDECAR_SCRIPT} — reinstall OmniVoice."
)
return True, "ok (CUDA when present, else CPU)"
@classmethod
def venv_python(cls):
from engines.moss_tts_v15.bootstrap import resolve_moss_tts_v15_venv
return resolve_moss_tts_v15_venv()
@classmethod
def sidecar_script(cls):
from engines.moss_tts_v15.bootstrap import MOSS_TTS_V15_SIDECAR_SCRIPT
return MOSS_TTS_V15_SIDECAR_SCRIPT
# ── TTSBackend protocol ────────────────────────────────────────────────
@property
def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
# 31 languages with multilingual handling; expose "multi" on the
# protocol surface (same as OmniVoice / CosyVoice / Supertonic-3) and
# translate the caller's language at synthesize time.
return ["multi"]
# ── generate (parent-side arbitration) ─────────────────────────────────
def generate(self, text: str, **kw) -> "torch.Tensor":
"""Synthesize one utterance through the MOSS-TTS-v1.5 sidecar.
kwargs honored:
* ``ref_audio`` — path to a reference clip. When present, MOSS
runs zero-shot voice cloning (``reference=``).
Optional: without it the model uses its own
default voice.
* ``ref_text`` — accepted but unused in clone mode (MOSS's
zero-shot path needs only the audio); kept in
the signature so the common call-site doesn't
need engine-specific knowledge.
* ``language`` — ISO code or name; mapped to a MOSS language name
in the sidecar, omitted (auto-detect) if unknown.
* ``duration`` — target seconds → ``tokens`` (1 s ≈ 12.5 tokens).
* ``max_new_tokens`` — generation cap (default 4096).
Returns a tensor of shape (1, n_samples) at :attr:`sample_rate`.
"""
forwarded: dict = {}
ref_audio = kw.get("ref_audio")
if ref_audio:
forwarded["ref_audio"] = ref_audio
ref_text = kw.get("ref_text")
if ref_text:
forwarded["ref_text"] = ref_text
language = kw.get("language")
if language:
forwarded["language"] = str(language)
duration = kw.get("duration")
if duration is not None:
target_tokens = int(float(duration) * TOKENS_PER_SECOND)
if target_tokens > 0:
forwarded["tokens"] = target_tokens
max_new_tokens = kw.get("max_new_tokens")
if max_new_tokens is not None:
forwarded["max_new_tokens"] = int(max_new_tokens)
return super().generate(text, **forwarded)
__all__ = ["MossTTSV15Backend", "TOKENS_PER_SECOND"]