Merge pull request #2025 from debpalash/feat/cosyvoice3-isolated

feat(engines): CosyVoice 3 installs in one click into its own venv
This commit is contained in:
Palash Debnath
2026-09-10 12:33:01 -07:00
committed by GitHub
12 changed files with 1098 additions and 43 deletions
+1
View File
@@ -12,6 +12,7 @@ the frozen-backend fallback mirror it for their toolchains.
- VoxCPM2 installs in one click into its own environment, with the CUDA build of PyTorch on NVIDIA GPUs (#2021)
- MOSS-TTS-Nano installs in one click into its own environment, pinned to a reviewed upstream commit it works with (#2022)
- CosyVoice 3 installs in one click into its own environment, with a trimmed dependency set that needs no TensorRT, DeepSpeed or third-party package feed (#2025)
### CI
@@ -0,0 +1,102 @@
"""cosyvoice-subprocess: CosyVoice 3 from its own venv (one-click install).
The in-process engine needs CosyVoice importable from VoiceStudio's own
interpreter, which upstream's setup (its own Python 3.10 environment, pins
that conflict with the app's) never provides. The one-click installer clones a
reviewed CosyVoice commit and the Matcha-TTS code it vendors as a submodule
into ``DATA_DIR/engines/cosyvoice/``, builds a Python 3.10 venv from a trimmed
requirements list (``requirements.txt`` beside this file), downloads the
CosyVoice 3 weights, and this class runs the model there in a sidecar.
The engine id stays ``cosyvoice``. ``tts_backend._effective_backend_class``
resolves to this class once that venv exists, and to the in-process
``CosyVoiceBackend`` otherwise, so an existing source installation keeps
working as it did.
"""
from __future__ import annotations
import math
import os
from pathlib import Path
from services.subprocess_backend import SubprocessBackend
VENV_ENV_VAR = "OMNIVOICE_COSYVOICE_DIR"
#: Where the installer puts the CosyVoice 3 weights, inside the checkout.
MANAGED_MODEL_SUBDIR = "pretrained_models/Fun-CosyVoice3-0.5B"
def own_venv_python() -> "Path | None":
"""The venv the one-click installer made for CosyVoice, if any."""
from services.sidecar_install import engine_venv_python
return engine_venv_python(VENV_ENV_VAR)
class CosyVoiceSubprocessBackend(SubprocessBackend):
"""CosyVoice in a killable sidecar running the engine's own venv."""
id = "cosyvoice"
display_name = "CosyVoice 3 (9 langs, zero-shot, instruct, Apache-2.0)"
gpu_compat = ("cuda", "cpu")
_DEFAULT_SAMPLE_RATE = 24_000
@classmethod
def is_available(cls) -> tuple[bool, str]:
if own_venv_python() is None:
return False, (
"cosyvoice package not installed. Install it from "
"Model Catalogue → Engines."
)
return True, "ready"
@classmethod
def venv_python(cls) -> Path:
py = own_venv_python()
if py is None:
raise RuntimeError(
"CosyVoice's environment is missing. Reinstall it from "
"Model Catalogue → Engines."
)
return py
@classmethod
def sidecar_script(cls) -> Path:
return Path(__file__).resolve().parent / "main.py"
@property
def recv_timeout_s(self) -> float:
# Loading the model and its text normalizers takes a while on a cold
# start; the sidecar heartbeats progress frames meanwhile.
try:
v = float(os.environ.get("OMNIVOICE_COSYVOICE_RECV_TIMEOUT_S", "900"))
except (TypeError, ValueError):
return 900.0
if not math.isfinite(v): # reject inf/nan so the deadline can't be disabled
return 900.0
return max(30.0, v)
@property
def sample_rate(self) -> int:
# The sidecar resamples to this rate if a model ever reports another.
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
from services.tts_backend import CosyVoiceBackend
return CosyVoiceBackend.supported_languages.fget(self)
def model_identity(self) -> str:
# v1/v2/v3 share the one "cosyvoice" id; the folder name tells them
# apart, as it does for the in-process engine.
model_dir = os.environ.get("OMNIVOICE_COSYVOICE_MODEL") or MANAGED_MODEL_SUBDIR
return os.path.basename(os.path.normpath(model_dir))
__all__ = [
"CosyVoiceSubprocessBackend",
"MANAGED_MODEL_SUBDIR",
"VENV_ENV_VAR",
"own_venv_python",
]
@@ -0,0 +1,299 @@
"""cosyvoice sidecar: CosyVoice in the engine's own venv (one-click install).
Launched as ``<engine venv python> main.py`` by CosyVoiceSubprocessBackend.
The venv holds only CosyVoice's own dependencies, so this file imports nothing
from the app. CosyVoice is not a package: like upstream's own ``example.py``,
this puts the checkout and its ``third_party/Matcha-TTS`` on ``sys.path``.
The mode mapping mirrors the in-process CosyVoiceBackend. For a CosyVoice 3
model, prompts also take the system-prompt prefix upstream's v3 examples use
(``You are a helpful assistant.<|endofprompt|>``), and with no reference clip
the model speaks in the voice of upstream's own sample prompt, because v3
ships no built-in speakers.
Wire protocol: identical to the other sidecars (engines/pockettts/main.py).
"""
from __future__ import annotations
import base64
import contextlib
import json
import os
import re
import struct
import sys
import threading
import traceback
# Mirrors services/subprocess_backend.py::MAX_FRAME_BYTES.
MAX_FRAME_BYTES = 64 * 1024 * 1024
#: The rate the engine reports; a model's output is resampled to it if needed.
COSYVOICE_SAMPLE_RATE = 24_000
_HEARTBEAT_S = 5.0
#: ref_audio must be a local file path, not a URL (local-first; no SSRF).
_URL_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://", re.IGNORECASE)
#: Where the installer puts the CosyVoice 3 weights, inside the checkout.
_MANAGED_MODEL_SUBDIR = ("pretrained_models", "Fun-CosyVoice3-0.5B")
#: Upstream's sample prompt, the default voice for a v3 model.
_DEFAULT_PROMPT_CLIP = ("asset", "zero_shot_prompt.wav")
_ENDOFPROMPT = "<|endofprompt|>"
_SYSTEM_PROMPT = "You are a helpful assistant."
#: Cross-lingual language tags for v1/v2 models (the in-process mapping).
_LANG_TAGS = {
"zh": "<|zh|>", "en": "<|en|>", "ja": "<|ja|>",
"ko": "<|ko|>", "yue": "<|yue|>", "de": "<|de|>",
"es": "<|es|>", "fr": "<|fr|>", "it": "<|it|>",
"ru": "<|ru|>",
}
_MODEL = None
# -- wire protocol -----------------------------------------------------------
_send_lock = threading.Lock()
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
with _send_lock:
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"))
def _measure_vram_mb() -> float:
try:
import torch # noqa: PLC0415
if torch.cuda.is_available():
return float(torch.cuda.memory_allocated()) / (1024 * 1024)
except Exception: # noqa: BLE001 — a probe, never fatal
pass
return 0.0
@contextlib.contextmanager
def _heartbeat(stdout, stage: str):
stop = threading.Event()
def beat() -> None:
pct = 1
while not stop.wait(_HEARTBEAT_S):
pct = min(pct + 1, 99)
_send(stdout, {"op": "progress", "stage": stage, "percent": pct})
thread = threading.Thread(target=beat, daemon=True)
thread.start()
try:
yield
finally:
stop.set()
thread.join(timeout=_HEARTBEAT_S + 1)
# -- loading -----------------------------------------------------------------
def _checkout() -> str:
path = os.environ.get("OMNIVOICE_COSYVOICE_DIR")
if not path:
raise RuntimeError(
"OMNIVOICE_COSYVOICE_DIR is not set. Reinstall CosyVoice from "
"Model Catalogue → Engines."
)
return path
def _model_dir(checkout: str) -> str:
override = os.environ.get("OMNIVOICE_COSYVOICE_MODEL")
if not override:
return os.path.join(checkout, *_MANAGED_MODEL_SUBDIR)
if not os.path.isdir(override):
# Falling back to the installed model would synthesize with a model
# and voice the user did not choose, while model_identity() still
# named theirs. Say what is wrong instead.
raise RuntimeError(
f"OMNIVOICE_COSYVOICE_MODEL points at {override}, which is not a "
"folder. Point it at a CosyVoice model folder, or clear it to use "
"the model the one-click install downloaded."
)
return override
def _load_model(stdout):
global _MODEL
if _MODEL is not None:
return _MODEL
checkout = _checkout()
model_dir = _model_dir(checkout)
# Never hand AutoModel a folder that is missing: it would treat the name as
# a ModelScope id and start a download the user never asked for.
if not os.path.isdir(model_dir):
raise RuntimeError(
f"CosyVoice model folder is missing ({model_dir}). Reinstall "
"CosyVoice from Model Catalogue → Engines."
)
for path in (os.path.join(checkout, "third_party", "Matcha-TTS"), checkout):
if path not in sys.path:
sys.path.insert(0, path)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
with _heartbeat(stdout, "loading_model"):
from cosyvoice.cli.cosyvoice import AutoModel # type: ignore[import-not-found] # noqa: PLC0415
_MODEL = AutoModel(model_dir=model_dir)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _MODEL
# -- synthesis ---------------------------------------------------------------
def _is_v3(model) -> bool:
return type(model).__name__ == "CosyVoice3"
def _v3_prompt(text: str) -> str:
"""v3 wants the system prompt ahead of the prompt transcript or text."""
return text if _ENDOFPROMPT in text else f"{_SYSTEM_PROMPT}{_ENDOFPROMPT}{text}"
def _instruct(text: str, v3: bool) -> str:
if not text.endswith(_ENDOFPROMPT):
text = f"{text}{_ENDOFPROMPT}"
if v3 and not text.startswith(_SYSTEM_PROMPT):
text = f"{_SYSTEM_PROMPT} {text}"
return text
def _lang_tag(language) -> str:
if not language:
return ""
full = str(language).lower()
return _LANG_TAGS.get(full) or _LANG_TAGS.get(full[:2], "")
def _run_model(model, msg: dict):
text = msg.get("text")
ref_audio = msg.get("ref_audio") or None
ref_text = msg.get("ref_text") or None
instruct = msg.get("instruct") or None
v3 = _is_v3(model)
if not ref_audio and v3:
ref_audio = os.path.join(_checkout(), *_DEFAULT_PROMPT_CLIP)
ref_text = None # the sample's transcript is not ours to supply
if instruct and ref_audio:
return model.inference_instruct2(text, _instruct(instruct, v3), ref_audio, stream=False)
if ref_audio and ref_text:
prompt_text = _v3_prompt(ref_text) if v3 else ref_text
return model.inference_zero_shot(text, prompt_text, ref_audio, stream=False)
if ref_audio:
tts_text = _v3_prompt(text) if v3 else f"{_lang_tag(msg.get('language'))}{text}"
return model.inference_cross_lingual(tts_text, ref_audio, stream=False)
speakers = model.list_available_spks()
if not speakers:
raise ValueError("This CosyVoice model has no built-in voices; pass a reference clip.")
return model.inference_sft(text, speakers[0], stream=False)
def _mono_pcm_b64(chunks, sample_rate: int) -> tuple[str, int]:
import numpy as np # noqa: PLC0415
import torch # noqa: PLC0415
pieces = [c["tts_speech"] for c in chunks if c.get("tts_speech") is not None]
if not pieces:
raise RuntimeError("CosyVoice produced no audio")
wav = torch.cat([torch.as_tensor(p, dtype=torch.float32).reshape(-1) for p in pieces])
if sample_rate != COSYVOICE_SAMPLE_RATE:
import torchaudio # noqa: PLC0415
wav = torchaudio.functional.resample(wav, sample_rate, COSYVOICE_SAMPLE_RATE)
arr = np.clip(wav.detach().cpu().numpy(), -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(arr.shape[-1])
def _handle_synthesize(msg: dict, stdout) -> None:
text = msg.get("text")
if not text or not isinstance(text, str):
raise ValueError("synthesize: missing or non-string 'text'")
ref_audio = msg.get("ref_audio") or None
if ref_audio and _URL_RE.match(str(ref_audio)):
raise ValueError(
"ref_audio must be a local file path; URLs are not accepted (local-first)."
)
model = _load_model(stdout)
chunks = list(_run_model(model, msg))
sample_rate = int(getattr(model, "sample_rate", COSYVOICE_SAMPLE_RATE) or COSYVOICE_SAMPLE_RATE)
pcm_b64, n_samples = _mono_pcm_b64(chunks, sample_rate)
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": COSYVOICE_SAMPLE_RATE,
"n_samples": n_samples,
})
# -- main loop ---------------------------------------------------------------
def main() -> int:
stdin = sys.stdin.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428): the
# libraries this loads print to fd 1, and those bytes would otherwise
# interleave with the length-prefixed frames.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
_send(stdout, {"op": "ready", "engine": "cosyvoice", "sample_rate": COSYVOICE_SAMPLE_RATE})
while True:
try:
msg = _recv(stdin)
except Exception as exc: # noqa: BLE001
_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", "vram_mb": _measure_vram_mb()})
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: # noqa: BLE001
_send(stdout, {
"op": "error",
"stage": op or "unknown",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,53 @@
# CosyVoice 3 inference requirements for VoiceStudio's one-click install.
#
# Derived from upstream's requirements.txt at FunAudioLLM/CosyVoice@074ca6dc
# (2026-05-25) and trimmed to what synthesis needs. What differs, and why:
#
# - No --extra-index-url lines. Upstream adds PyTorch's cu121 index and a
# third-party Azure DevOps feed for onnxruntime-gpu. The installer chooses
# the PyTorch build itself and uses no third-party index.
# - torch and torchaudio are not listed here. The installer pins the 2.7.0
# pair per host: +cu128 on NVIDIA GPUs, +cpu on other Windows and Linux
# machines, the regular build on macOS. Upstream's 2.3.1 exists only for
# CUDA 12.1 and cannot run on RTX 50-series GPUs.
# - Raised past published security advisories, where upstream pins an
# affected release: diffusers, hydra-core, lightning, modelscope, onnx,
# protobuf and transformers. This exact set was installed on Windows and
# passes the installer's import probe (tests pin the advisory floors).
# - Dropped: deepspeed and tensorrt-cu12* (Linux-only acceleration);
# onnxruntime-gpu (the onnxruntime below serves every host); pyworld and
# pyarrow (not needed to import or run CosyVoice; pyworld has no Python 3.10
# wheels for Linux or macOS, pyarrow carried an advisory); wetext (its data
# is published only on ModelScope, which rate-limits downloads, and a
# half-downloaded normaliser failed silently; CosyVoice reads text as
# written without it); and the web UI, server, training and download tools
# (fastapi, fastapi-cli, gradio, grpcio, grpcio-tools, uvicorn, tensorboard,
# gdown, wget). Upstream's full list cannot be installed together anyway:
# its fastapi pin conflicts.
# - openai-whisper 20231117 -> 20250625: the older release needs
# pkg_resources at build time and fails to build. CosyVoice only uses its
# mel-spectrogram frontend.
#
# Everything else keeps upstream's exact pin. tests/test_cosyvoice_subprocess.py
# checks this file.
conformer==0.3.2
diffusers==0.39.0
hydra-core==1.3.6
HyperPyYAML==1.2.3
inflect==7.3.1
librosa==0.10.2
lightning==2.6.6
matplotlib==3.7.5
modelscope==1.40.0
networkx==3.1
numpy==1.26.4
omegaconf==2.3.0
onnx==1.22.0
onnxruntime==1.18.0
openai-whisper==20250625
protobuf==4.25.9
pydantic==2.7.0
rich==13.7.1
soundfile==0.12.1
transformers==4.57.6
x-transformers==2.11.24
+5 -1
View File
@@ -575,7 +575,11 @@ async def preflight(
return
from services.sidecar_install import SPECS # noqa: PLC0415
sidecar_repos = {s.weights_repo_id for s in SPECS.values()}
# Weights only an engine installer can place are not offered as a
# plain download; ones the Model Catalogue also serves still are.
sidecar_repos = {
s.weights_repo_id for s in SPECS.values() if not s.weights_catalogue_download
}
raise ModelNotDownloaded(
engine=engine,
repo_ids=repo_ids,
+178 -18
View File
@@ -57,7 +57,7 @@ import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from typing import Callable, NamedTuple, Optional
from core.config import DATA_DIR
from core.contained_subprocess import OwnedPopen, WindowsJobPopen, spawn_owned
@@ -88,6 +88,17 @@ _IMPORT_PROBE_TIMEOUT_S = 120
# ── Spec ───────────────────────────────────────────────────────────────────
class ExtraSource(NamedTuple):
"""A second pinned source tree fetched into the checkout: an upstream git
submodule, which neither a depth-1 clone nor GitHub's source tarball
includes. Always fetched as the tarball of its pinned commit."""
path: str # where it goes, relative to the checkout
revision: str # reviewed upstream commit
tarball_url: str # GitHub archive of that commit
required_path: str # a file, relative to *path*, proving the tree is there
@dataclass(frozen=True)
class SidecarSpec:
"""Everything the provisioner needs to install one sidecar engine.
@@ -155,6 +166,18 @@ class SidecarSpec:
# pinned pair: `+cu128` on a CUDA host, `+cpu` on other Windows and Linux
# hosts, plain on macOS.
torch_pins: tuple[str, ...] = ()
# Submodule trees the upstream repository needs (see ExtraSource).
extra_sources: tuple[ExtraSource, ...] = ()
# Download only these files of the weights repo (huggingface_hub
# allow_patterns). Empty downloads the whole repository.
weights_allow_patterns: tuple[str, ...] = ()
# Require the completion marker the import probe writes. Only IndexTTS,
# installed before the marker existed, opts out.
requires_install_marker: bool = True
# The weights repo is also an ordinary Model Catalogue download that the
# engine's in-process path uses (CosyVoice). Otherwise it is one only the
# installer can place, and a plain download is not offered for it.
weights_catalogue_download: bool = False
# Can the one-click install work on THIS machine? (ok, reason). Consulted
# before an Install button is offered and again when an install starts, so
# a host the upstream does not support never gets a job that can only fail.
@@ -263,6 +286,13 @@ def _in_app_env(module: str) -> Callable[[], bool]:
return probe
# The trimmed CosyVoice requirements ship with the app (see that file for
# what was dropped from upstream's list and why).
_COSYVOICE_REQUIREMENTS = str(
Path(__file__).resolve().parents[1] / "engines" / "cosyvoice_subprocess" / "requirements.txt"
)
SPECS: dict[str, SidecarSpec] = {
"indextts2": SidecarSpec(
engine_id="indextts2",
@@ -294,6 +324,9 @@ SPECS: dict[str, SidecarSpec] = {
disk_confidence="estimated",
invalidate=_indextts_invalidate,
installed_probe=_indextts_installed,
# Installed before the completion marker existed; its weights
# marker already proves a finished install.
requires_install_marker=False,
),
# Pinned to the upstream commits current on 2026-09-10. Weights are not
# fetched here: each engine downloads them into the shared HF cache on its
@@ -476,6 +509,66 @@ SPECS: dict[str, SidecarSpec] = {
"MOSS-TTS-Nano pins a PyTorch version that has no Intel Mac build."
),
),
# A reviewed commit (2026-05-25) plus the Matcha-TTS submodule it imports.
# No pyproject: its dependencies come from the trimmed requirements file,
# and like upstream's example.py the sidecar puts the checkout and
# third_party/Matcha-TTS on sys.path. Only the CosyVoice 3 weights it loads
# are downloaded (about 5.4 of 9.8 GB).
"cosyvoice": SidecarSpec(
engine_id="cosyvoice",
display_name="CosyVoice 3",
repo_url="https://github.com/FunAudioLLM/CosyVoice.git",
tarball_url=(
"https://github.com/FunAudioLLM/CosyVoice/archive/"
"074ca6dc9e80a2f424f1f74b48bdd7d3fea531cc.tar.gz"
),
checkout_dirname="CosyVoice",
env_var="OMNIVOICE_COSYVOICE_DIR",
probe_module="cosyvoice",
probe_code=(
"import os, sys; c = {checkout_repr}; "
"sys.path[:0] = [c, os.path.join(c, 'third_party', 'Matcha-TTS')]; "
"from cosyvoice.cli.cosyvoice import AutoModel"
),
source_revision="074ca6dc9e80a2f424f1f74b48bdd7d3fea531cc",
source_manifest="requirements.txt",
source_required_path="cosyvoice/cli/cosyvoice.py",
extra_sources=(
ExtraSource(
path="third_party/Matcha-TTS",
revision="dd9105b34bf2be2230f4aa1e4769fb586a3c824e",
tarball_url=(
"https://github.com/shivammehta25/Matcha-TTS/archive/"
"dd9105b34bf2be2230f4aa1e4769fb586a3c824e.tar.gz"
),
required_path="matcha/__init__.py",
),
),
venv_args=("--python", "3.10"),
install_args=("-r", _COSYVOICE_REQUIREMENTS),
torch_pins=("torch==2.7.0", "torchaudio==2.7.0"),
weights_repo_id="FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
weights_revision="29e01c4e8d000f4bcd70751be16fa94bf3d85a18",
weights_subdir="pretrained_models/Fun-CosyVoice3-0.5B",
weights_config_names=("cosyvoice3.yaml",),
# Also a Model Catalogue download, used by the in-process engine and
# by remote workers that run it.
weights_catalogue_download=True,
weights_allow_patterns=(
"cosyvoice3.yaml", "config.json", "configuration.json",
"campplus.onnx", "speech_tokenizer_v3.onnx",
"llm.pt", "flow.pt", "hift.pt", "CosyVoice-BlankEN/*",
),
docs_path="docs/engines/cosyvoice.md",
# ~0.1 GB source + ~7 GB venv (CUDA torch) + ~5.4 GB weights.
required_bytes=14 * _GIB,
weights_bytes=6 * _GIB,
dependency_bytes=7 * _GIB,
installed_probe=_in_app_env("cosyvoice"),
host_supported=_no_intel_mac(
"CosyVoice 3 needs a PyTorch version that has no Intel Mac build."
),
),
}
@@ -881,12 +974,14 @@ def _healthy(spec: SidecarSpec) -> bool:
return False
if not _venv_python(checkout / ".venv").is_file():
return False
if spec.weights_repo_id:
return _weights_present(spec)
# Nothing downloaded after the dependencies proves they finished; only
# the marker the import probe writes does. IndexTTS (weights) predates
# the marker and keeps its own check, so no existing install is asked
# to reinstall.
if spec.weights_repo_id and not _weights_present(spec):
return False
# Weights left by an earlier run do not prove this run's dependencies
# finished; only the marker the import probe writes does. IndexTTS
# predates the marker and keeps its weights check, so no existing install
# is asked to reinstall.
if not spec.requires_install_marker:
return True
return (checkout / _INSTALL_COMPLETE_MARKER).is_file()
@@ -1045,6 +1140,12 @@ def _step_preflight(spec: SidecarSpec, job: dict) -> None:
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
_fetch_main_source(spec, job)
if spec.has_source and spec.extra_sources:
_ensure_extra_sources(spec, job, managed_checkout(spec))
def _fetch_main_source(spec: SidecarSpec, job: dict) -> None:
step = _job_step(job, "fetch_source")
checkout = managed_checkout(spec)
if not spec.has_source:
@@ -1133,22 +1234,23 @@ def _source_present(spec: SidecarSpec, checkout: Path) -> bool:
return marker == spec.source_revision
def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
"""Download + extract the GitHub source tarball (no git required).
def _download_and_extract(job: dict, url: str, dest: Path, work_root: Path, env_var: str) -> None:
"""Download a GitHub source tarball and move its one top-level directory
to *dest* (no git required).
Extraction is member-validated (no absolute paths / parent escapes) and
never uses symlinks, so it behaves identically on Windows.
"""
import httpx
root = managed_root(spec)
root = work_root
root.mkdir(parents=True, exist_ok=True)
_log(job, f"Downloading {spec.tarball_url}")
_log(job, f"Downloading {url}")
fd, tmp_tar = tempfile.mkstemp(suffix=".tar.gz", dir=str(root))
try:
with os.fdopen(fd, "wb") as out:
with httpx.stream(
"GET", spec.tarball_url, follow_redirects=True,
"GET", url, follow_redirects=True,
timeout=_TARBALL_TIMEOUT_S,
) as resp:
resp.raise_for_status()
@@ -1158,7 +1260,7 @@ def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
with tempfile.TemporaryDirectory(dir=str(root)) as tmp_dir:
with tarfile.open(tmp_tar, "r:gz") as tf:
try:
tf.extractall(tmp_dir, filter="data") # stdlib safe-extract (3.11.4+)
tf.extractall(tmp_dir, members=_members_without_links(tf), filter="data")
except TypeError: # pragma: no cover — pre-filter= interpreters
_safe_extract_members(tf, tmp_dir)
entries = [p for p in Path(tmp_dir).iterdir() if p.is_dir()]
@@ -1166,10 +1268,10 @@ def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
raise _StepError(
f"Unexpected tarball layout ({len(entries)} top-level dirs).",
"Re-run the install; if it keeps failing, clone the repository "
f"manually and set {spec.env_var} (see the engine docs).",
f"manually and set {env_var} (see the engine docs).",
)
# os.replace-style move keeps this atomic-ish on the same volume.
shutil.move(str(entries[0]), str(checkout))
shutil.move(str(entries[0]), str(dest))
finally:
try:
os.unlink(tmp_tar)
@@ -1177,6 +1279,53 @@ def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
pass # temp tarball already gone / locked — harmless leftover
def _fetch_tarball(spec: SidecarSpec, job: dict, checkout: Path) -> None:
"""Download + extract the engine's GitHub source tarball (no git required)."""
_download_and_extract(job, spec.tarball_url, checkout, managed_root(spec), spec.env_var)
def _extra_source_present(extra: ExtraSource, dest: Path) -> bool:
if not (dest / extra.required_path).is_file():
return False
try:
marker = (dest / _SOURCE_REVISION_MARKER).read_text(encoding="utf-8").strip()
except OSError:
return False
return marker == extra.revision
def _ensure_extra_sources(spec: SidecarSpec, job: dict, checkout: Path) -> None:
for extra in spec.extra_sources:
dest = checkout / extra.path
if _extra_source_present(extra, dest):
continue
shutil.rmtree(dest, ignore_errors=True)
dest.parent.mkdir(parents=True, exist_ok=True)
_log(job, f"Fetching {extra.path} at {extra.revision[:8]}")
_download_and_extract(job, extra.tarball_url, dest, managed_root(spec), spec.env_var)
if not (dest / extra.required_path).is_file():
raise _StepError(
f"Fetched {extra.path} has no {extra.required_path}; the download "
"appears incomplete or the upstream layout changed.",
"Re-run the install; if it keeps failing, see the engine docs.",
)
(dest / _SOURCE_REVISION_MARKER).write_text(f"{extra.revision}\n", encoding="utf-8")
def _members_without_links(tf: "tarfile.TarFile") -> "list[tarfile.TarInfo]":
"""Every member except links.
The stdlib "data" filter raises on a link to an absolute path, and the
pinned Matcha-TTS tarball (a CosyVoice submodule) ships one: ``data``
points at its author's own training-data folder. That aborted the whole
fetch. No installer here uses a link from a source tree, symlinks need
privileges on Windows, and the pre-3.11.4 path below already drops them,
so both paths behave the same. Everything that IS extracted still goes
through the "data" filter.
"""
return [m for m in tf.getmembers() if not (m.issym() or m.islnk())]
def _safe_extract_members(tf: "tarfile.TarFile", dest: str) -> None:
"""Tar-slip-guarded extraction for interpreters without
``extractall(filter="data")`` (Python < 3.11.4).
@@ -1258,11 +1407,20 @@ def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
env=uv_subprocess_env(Path(DATA_DIR) / "engines"),
)
if rc != 0:
raise _StepError(
f"uv pip install failed (exit {rc}).",
hint = (
"Usually a network hiccup — re-run the install to resume. Behind a "
"proxy, set HTTPS_PROXY in Settings → Environment first.",
"proxy, set HTTPS_PROXY in Settings → Environment first."
)
if sys.platform == "win32":
# Packages built from source (openai-whisper, for CosyVoice) nest
# deep build folders under uv's cache; past Windows' 260-character
# limit the build fails with "No such file or directory".
hint += (
" If the log shows \"No such file or directory\" while building a "
"package, the path is too long for Windows: turn on Windows "
"long-path support (the LongPathsEnabled setting) and re-run."
)
raise _StepError(f"uv pip install failed (exit {rc}).", hint)
_job_step(job, "install_deps")["detail"] = "dependencies installed"
@@ -1392,6 +1550,8 @@ def _step_fetch_weights(spec: SidecarSpec, job: dict) -> None:
}
if spec.weights_revision:
kwargs["revision"] = spec.weights_revision
if spec.weights_allow_patterns:
kwargs["allow_patterns"] = list(spec.weights_allow_patterns)
endpoint = endpoint_race.effective_endpoint()
if endpoint:
kwargs["endpoint"] = endpoint
+1
View File
@@ -2664,6 +2664,7 @@ def list_backends(*, include_hidden: bool = False) -> list[dict]:
_OWN_VENV_SIDECARS: dict[str, tuple[str, str]] = {
"voxcpm2": ("engines.voxcpm2_subprocess", "VoxCPM2SubprocessBackend"),
"moss-tts-nano": ("engines.moss_tts_nano_subprocess", "MossTTSNanoSubprocessBackend"),
"cosyvoice": ("engines.cosyvoice_subprocess", "CosyVoiceSubprocessBackend"),
}
+1 -1
View File
@@ -44,7 +44,7 @@ VoiceStudio/
│ ├── engines/ per-engine adapters: indextts, supertonic3, confucius4,
│ │ dots_tts, moss_tts_v15, pockettts, audiocpp,
│ │ omnivoice_gguf, omnivoice_subprocess, _asr_sidecar, _echo,
│ │ voxcpm2_subprocess, moss_tts_nano_subprocess
│ │ voxcpm2_subprocess, moss_tts_nano_subprocess, cosyvoice_subprocess
│ ├── worker/ remote / distributed workers — scheduler, pool, routing,
│ │ breaker, capacity, plus protocol/ and inbound/
│ ├── mcp_shim/ MCP server entry point (docs/mcp.md)
+40 -16
View File
@@ -1,8 +1,9 @@
# VoiceStudio: CosyVoice Engine
CosyVoice is an optional multilingual TTS backend for zero-shot voice cloning
and instructed speech. VoiceStudio currently exposes it as an in-process
adapter rather than an isolated engine sidecar.
and instructed speech. A one-click install runs it in its own
environment and process; an existing source installation keeps
running in-process.
## Downloaded weights and an available engine are different states
@@ -23,27 +24,50 @@ It then loads the directory named by `OMNIVOICE_COSYVOICE_MODEL`, or defaults
to `pretrained_models/Fun-CosyVoice3-0.5B`. That path must resolve to the usable
model directory, not only the parent Hugging Face cache directory.
## Packaged builds
## One-click install
The current packaged app does not provide a one-click CosyVoice runtime
installer. Downloading the model weights from Model Catalogue does not install
the CosyVoice Python runtime or SoX. Re-downloading the weights will not repair
a missing runtime.
Click **Install** in **Model Catalogue → Engines → CosyVoice**. VoiceStudio
gives CosyVoice its own folder under the data directory and its own Python
3.10 environment, and runs it there in a separate process. The install:
Do not install CosyVoice requirements into an unrelated Python environment.
VoiceStudio will continue to report the engine unavailable because its backend
cannot import packages from that environment.
- clones a reviewed CosyVoice commit and the Matcha-TTS code it depends on;
- downloads the CosyVoice 3 weights (about 5.4 GB).
Use **Model Catalogue > Engines > CosyVoice > Re-check** to inspect the runtime
reason. If the row remains unavailable, use another engine that reports ready
or collect the diagnostics below. The packaged release has no supported direct
CosyVoice runtime installation path today.
It differs from upstream's own setup:
- **PyTorch 2.7.0.** The CUDA 12.8 build on an NVIDIA GPU, the CPU build on
other Windows and Linux machines, the regular build on Apple Silicon.
Upstream's 2.3.1 exists only for CUDA 12.1 and cannot run on RTX 50-series
GPUs.
- **Leaner dependencies.** No TensorRT, DeepSpeed or GPU onnxruntime, and no
third-party package index. Upstream uses them for extra speed on Linux;
synthesis works without them. Nothing needs a compiler, and SoX is not
needed.
- **Patched dependencies.** Where upstream pins a release with a published
security advisory (diffusers, hydra-core, lightning, modelscope, onnx,
protobuf, transformers), the install uses the fixed release. That set was
installed on Windows and passes the install's import check.
- **No text normaliser.** Upstream's (wetext) downloads its data from
ModelScope on every model load, and ModelScope rate-limits those
downloads, so a normaliser could half-download and fail silently. The
one-click install leaves it out, and CosyVoice reads text as written:
spell out numbers, dates and symbols where the pronunciation matters.
- **Only the weights CosyVoice 3 loads.** Not the RL and TensorRT variants
that share its repository.
Nothing it installs touches VoiceStudio itself or any other engine, and
**Uninstall** in the same row removes only that folder. An existing source
installation keeps working as it is. The button is not offered on Intel
Macs, where PyTorch 2.7.0 has no build.
CosyVoice 3 has no built-in voices. With no reference clip, it speaks in the
voice of upstream's own sample prompt.
## Source builds and existing installations
The upstream CosyVoice project recommends its own Python 3.10 Conda environment
and SoX. VoiceStudio does not currently bridge that separate interpreter to its
in-process adapter. Installing upstream dependency pins into VoiceStudio's
and SoX. The one-click install above gives CosyVoice that separate
environment. Installing upstream dependency pins into VoiceStudio's
shared backend environment can also conflict with other engines.
Existing source installations remain usable when the VoiceStudio backend's
+260
View File
@@ -0,0 +1,260 @@
"""CosyVoice 3 from its own venv: the sidecar, its requirements, and the switch.
The sidecar runs against a fake of upstream's `cosyvoice.cli.cosyvoice`, so
these tests pin how each request maps onto upstream's inference calls,
including the v3 system-prompt prefix upstream's own examples use.
"""
import importlib.util
import io
import json
import re
import struct
import sys
import types
from pathlib import Path
import pytest
import torch
_ROOT = Path(__file__).resolve().parents[1]
_MAIN = _ROOT / "backend/engines/cosyvoice_subprocess/main.py"
_REQUIREMENTS = _ROOT / "backend/engines/cosyvoice_subprocess/requirements.txt"
PREFIX = "You are a helpful assistant.<|endofprompt|>"
def _fake_model_class(name, calls, sample_rate=24000):
def method(kind):
def call(self, *args, **kwargs):
calls.append((kind, args, kwargs))
return iter([{"tts_speech": torch.full((1, 2400), 0.25)}])
return call
return type(name, (), {
"sample_rate": sample_rate,
"inference_instruct2": method("instruct2"),
"inference_zero_shot": method("zero_shot"),
"inference_cross_lingual": method("cross_lingual"),
"inference_sft": method("sft"),
"list_available_spks": lambda self: ["spk-a"],
})
def _load_sidecar(monkeypatch, tmp_path, calls, *, model_class="CosyVoice3", sample_rate=24000):
checkout = tmp_path / "CosyVoice"
(checkout / "pretrained_models" / "Fun-CosyVoice3-0.5B").mkdir(parents=True)
(checkout / "asset").mkdir()
(checkout / "asset" / "zero_shot_prompt.wav").write_bytes(b"RIFF")
monkeypatch.setenv("OMNIVOICE_COSYVOICE_DIR", str(checkout))
monkeypatch.delenv("OMNIVOICE_COSYVOICE_MODEL", raising=False)
cls = _fake_model_class(model_class, calls, sample_rate)
def AutoModel(**kwargs):
calls.append(("load", (), kwargs))
return cls()
cli = types.ModuleType("cosyvoice.cli.cosyvoice")
cli.AutoModel = AutoModel
monkeypatch.setitem(sys.modules, "cosyvoice", types.ModuleType("cosyvoice"))
monkeypatch.setitem(sys.modules, "cosyvoice.cli", types.ModuleType("cosyvoice.cli"))
monkeypatch.setitem(sys.modules, "cosyvoice.cli.cosyvoice", cli)
monkeypatch.setattr(sys, "path", list(sys.path))
spec = importlib.util.spec_from_file_location("_cosy_sidecar_under_test", _MAIN)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module, checkout
def _frames(buf):
data, out, i = buf.getvalue(), [], 0
while i < len(data):
(n,) = struct.unpack("!I", data[i:i + 4])
out.append(json.loads(data[i + 4:i + 4 + n]))
i += 4 + n
return out
def _last_call(calls):
return next(c for c in reversed(calls) if c[0] != "load")
def test_loads_the_installed_weights_with_matcha_on_the_path(monkeypatch, tmp_path):
calls = []
sidecar, checkout = _load_sidecar(monkeypatch, tmp_path, calls)
out = io.BytesIO()
sidecar._handle_synthesize({"text": "hello", "ref_audio": str(tmp_path / "r.wav")}, out)
load = next(c for c in calls if c[0] == "load")
assert load[2]["model_dir"] == str(checkout / "pretrained_models" / "Fun-CosyVoice3-0.5B")
assert str(checkout / "third_party" / "Matcha-TTS") in sys.path
audio = _frames(out)[-1]
assert audio["op"] == "audio" and audio["sample_rate"] == 24000 and audio["n_samples"] == 2400
def test_a_missing_model_folder_never_reaches_a_modelscope_download(monkeypatch, tmp_path):
calls = []
sidecar, checkout = _load_sidecar(monkeypatch, tmp_path, calls)
import shutil
shutil.rmtree(checkout / "pretrained_models")
with pytest.raises(RuntimeError, match="model folder is missing"):
sidecar._handle_synthesize({"text": "hi"}, io.BytesIO())
assert not any(c[0] == "load" for c in calls)
def test_v3_zero_shot_prefixes_the_prompt_transcript(monkeypatch, tmp_path):
calls = []
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, calls)
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav", "ref_text": "hello there"}, io.BytesIO())
kind, args, _ = _last_call(calls)
assert kind == "zero_shot"
assert args == ("hi", PREFIX + "hello there", "/r.wav")
def test_v3_instruct_uses_upstreams_system_prompt(monkeypatch, tmp_path):
calls = []
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, calls)
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav", "instruct": "Speak calmly."}, io.BytesIO())
kind, args, _ = _last_call(calls)
assert kind == "instruct2"
assert args == ("hi", "You are a helpful assistant. Speak calmly.<|endofprompt|>", "/r.wav")
def test_v3_cross_lingual_prefixes_the_text_without_v2_language_tags(monkeypatch, tmp_path):
calls = []
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, calls)
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav", "language": "en"}, io.BytesIO())
kind, args, _ = _last_call(calls)
assert kind == "cross_lingual"
assert args == (PREFIX + "hi", "/r.wav")
def test_v3_without_a_clip_speaks_in_upstreams_sample_voice(monkeypatch, tmp_path):
"""CosyVoice 3 ships no built-in speakers."""
calls = []
sidecar, checkout = _load_sidecar(monkeypatch, tmp_path, calls)
sidecar._handle_synthesize({"text": "hi"}, io.BytesIO())
kind, args, _ = _last_call(calls)
assert kind == "cross_lingual"
assert args == (PREFIX + "hi", str(checkout / "asset" / "zero_shot_prompt.wav"))
def test_a_v2_model_keeps_the_in_process_mapping(monkeypatch, tmp_path):
calls = []
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, calls, model_class="CosyVoice2")
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav", "language": "en"}, io.BytesIO())
assert _last_call(calls)[1] == ("<|en|>hi", "/r.wav")
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav", "ref_text": "hello"}, io.BytesIO())
assert _last_call(calls)[1] == ("hi", "hello", "/r.wav")
sidecar._handle_synthesize({"text": "hi"}, io.BytesIO())
assert _last_call(calls)[:2] == ("sft", ("hi", "spk-a"))
def test_a_model_folder_override_is_honoured(monkeypatch, tmp_path):
calls = []
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, calls)
other = tmp_path / "my-model"
other.mkdir()
monkeypatch.setenv("OMNIVOICE_COSYVOICE_MODEL", str(other))
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav"}, io.BytesIO())
assert next(c for c in calls if c[0] == "load")[2]["model_dir"] == str(other)
def test_output_is_resampled_to_the_reported_rate(monkeypatch, tmp_path):
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, [], sample_rate=48000)
out = io.BytesIO()
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav"}, out)
assert _frames(out)[-1]["n_samples"] == 1200
def test_rejects_a_url_reference(monkeypatch, tmp_path):
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, [])
with pytest.raises(ValueError, match="local file path"):
sidecar._handle_synthesize({"text": "hi", "ref_audio": "https://x.test/a.wav"}, io.BytesIO())
def test_the_sidecar_imports_nothing_from_the_app():
src = _MAIN.read_text(encoding="utf-8")
for name in ("services", "core", "engines", "api", "backend", "utils"):
assert not re.search(rf"^\s*(from|import) {name}\b", src, re.M), name
def test_requirements_drop_what_the_one_click_install_must_not_pull():
"""No third-party index, no Linux-only acceleration, no web UI stack, and
torch left to the installer's per-host pins."""
lines = [
line.split("#", 1)[0].strip()
for line in _REQUIREMENTS.read_text(encoding="utf-8").splitlines()
]
reqs = [line for line in lines if line]
names = {re.split(r"[=<>!~ ;\[]", r, maxsplit=1)[0].lower() for r in reqs}
assert not any(r.startswith("-") for r in reqs), "no index or option lines"
for dropped in ("torch", "torchaudio", "deepspeed", "tensorrt-cu12", "onnxruntime-gpu",
"fastapi", "gradio", "uvicorn", "grpcio", "tensorboard",
"wetext", "pyarrow", "pyworld"):
assert dropped not in names, dropped
assert "openai-whisper==20250625" in reqs # 20231117 cannot build
assert all("==" in r for r in reqs), "every requirement stays pinned"
def test_the_class_switches_to_the_sidecar_once_its_venv_exists(monkeypatch, tmp_path):
from engines.cosyvoice_subprocess import CosyVoiceSubprocessBackend
from services import tts_backend
from services.sidecar_install import _INSTALL_COMPLETE_MARKER, _venv_python
monkeypatch.setenv("OMNIVOICE_COSYVOICE_DIR", "")
monkeypatch.delenv("OMNIVOICE_COSYVOICE_DIR")
monkeypatch.delenv("OMNIVOICE_COSYVOICE_MODEL", raising=False)
assert tts_backend.get_backend_class("cosyvoice") is tts_backend.CosyVoiceBackend
py = _venv_python(tmp_path / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
(tmp_path / _INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
monkeypatch.setenv("OMNIVOICE_COSYVOICE_DIR", str(tmp_path))
cls = tts_backend.get_backend_class("cosyvoice")
assert cls is CosyVoiceSubprocessBackend
assert cls.is_available() == (True, "ready")
for attr in ("id", "display_name", "gpu_compat"):
assert getattr(cls, attr) == getattr(tts_backend.CosyVoiceBackend, attr), attr
backend = cls()
assert backend.supported_languages == tts_backend.CosyVoiceBackend().supported_languages
assert backend.model_identity() == "Fun-CosyVoice3-0.5B"
def test_a_missing_model_override_is_an_error_not_a_silent_swap(monkeypatch, tmp_path):
"""Loading the installed model instead would speak with a model and voice
the user did not choose, while model_identity() still named theirs."""
calls = []
sidecar, _ = _load_sidecar(monkeypatch, tmp_path, calls)
monkeypatch.setenv("OMNIVOICE_COSYVOICE_MODEL", str(tmp_path / "moved-away"))
with pytest.raises(RuntimeError, match="OMNIVOICE_COSYVOICE_MODEL"):
sidecar._handle_synthesize({"text": "hi", "ref_audio": "/r.wav"}, io.BytesIO())
assert not any(c[0] == "load" for c in calls)
# The first release of each package that fixes the advisories upstream's pins
# fall under (OSV, checked 2026-09-10). Raising a pin is fine; going below
# one of these reintroduces a known vulnerability.
_ADVISORY_FLOORS = {
"diffusers": "0.38.0",
"hydra-core": "1.3.4",
"lightning": "2.6.6",
"modelscope": "1.27.0",
"onnx": "1.21.0",
"protobuf": "4.25.8",
"transformers": "4.53.0",
}
def test_requirements_stay_above_the_advisory_fixes():
from packaging.version import Version
pins = {}
for line in _REQUIREMENTS.read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if "==" in line:
name, version = line.split("==", 1)
pins[name.strip().lower()] = version.strip()
for name, floor in _ADVISORY_FLOORS.items():
assert name in pins, f"{name} is no longer pinned"
assert Version(pins[name]) >= Version(floor), f"{name}=={pins[name]} is below {floor}"
+157 -7
View File
@@ -563,8 +563,11 @@ def test_partial_install_is_not_already_installed(monkeypatch):
py.write_text("#!fake\n")
assert si._healthy(spec) is False
# Complete the weights (incl. the completion marker) → healthy flips true.
# Complete the weights (incl. the completion marker) and the install
# (the marker the import probe writes) → healthy flips true.
_write_weights(checkout / spec.weights_subdir, complete=True)
assert si._healthy(spec) is False
(checkout / si._INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
assert si._healthy(spec) is True
@@ -678,6 +681,7 @@ def test_healthy_managed_install_reheals_lost_env_var(monkeypatch):
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
_write_weights(checkout / spec.weights_subdir, complete=True)
(checkout / si._INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
prefs_written = {}
monkeypatch.setattr("core.prefs.set_", lambda k, v: prefs_written.update({k: v}))
assert "OMNIVOICE_FAKE_SIDE_DIR" not in os.environ
@@ -1090,12 +1094,12 @@ def test_verify_probe_runs_in_the_engines_venv_and_compiles(monkeypatch, engine_
@pytest.mark.parametrize(
("family", "platform", "machine", "expected"),
[
("cuda", "linux", "x86_64", {"moss-tts-v15", "dots-tts", "pockettts", "voxcpm2", "moss-tts-nano"}),
("cuda", "win32", "AMD64", {"moss-tts-v15", "pockettts", "voxcpm2", "moss-tts-nano"}),
("cpu", "win32", "AMD64", {"pockettts", "voxcpm2", "moss-tts-nano"}),
("mps", "darwin", "arm64", {"dots-tts", "pockettts", "voxcpm2", "moss-tts-nano"}),
# Intel Mac: PyTorch publishes no build PocketTTS, VoxCPM2 or
# MOSS-TTS-Nano can use.
("cuda", "linux", "x86_64", {"moss-tts-v15", "dots-tts", "pockettts", "voxcpm2", "moss-tts-nano", "cosyvoice"}),
("cuda", "win32", "AMD64", {"moss-tts-v15", "pockettts", "voxcpm2", "moss-tts-nano", "cosyvoice"}),
("cpu", "win32", "AMD64", {"pockettts", "voxcpm2", "moss-tts-nano", "cosyvoice"}),
("mps", "darwin", "arm64", {"dots-tts", "pockettts", "voxcpm2", "moss-tts-nano", "cosyvoice"}),
# Intel Mac: PyTorch publishes no build PocketTTS, VoxCPM2,
# MOSS-TTS-Nano or CosyVoice can use.
("cpu", "darwin", "x86_64", {"dots-tts"}),
],
)
@@ -1230,6 +1234,8 @@ _UPSTREAM_ROOT_FILES = {
"dots-tts": ("pyproject.toml", "README.md", "LICENSE", "constraints/recommended.txt"),
"moss-tts-nano": ("pyproject.toml", "moss_tts_nano_runtime.py", "requirements.txt",
"README.md", "LICENSE"),
"cosyvoice": ("requirements.txt", "README.md", "LICENSE", "cosyvoice/cli/cosyvoice.py",
"asset/zero_shot_prompt.wav"),
}
@@ -1253,6 +1259,8 @@ def test_a_real_upstream_layout_passes_source_validation(monkeypatch, engine_id)
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
monkeypatch.setattr(si, "_run_logged", fake_git)
monkeypatch.setattr(si, "_fetch_tarball", no_tarball)
# Submodule trees come from their own tarballs; not what this test covers.
monkeypatch.setattr(si, "_ensure_extra_sources", lambda spec, job, checkout: None)
job = si._new_job(engine_id)
si._step_fetch_source(spec, job)
@@ -1324,3 +1332,145 @@ def test_torch_pins_follow_the_host(monkeypatch, family, platform, suffix, index
assert pip[pip.index("--extra-index-url") + 1] == index
else:
assert "--extra-index-url" not in pip
# ── Submodule trees, partial weights, and optional post-install data ──────
def _submodule_tarball() -> bytes:
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
data = b"x = 1\n"
info = tarfile.TarInfo("Sub-abc123/sub/__init__.py")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def test_a_submodule_tree_is_fetched_at_its_pinned_revision(monkeypatch):
"""Neither a depth-1 clone nor GitHub's source tarball includes git
submodules, and CosyVoice imports Matcha-TTS from one."""
import httpx
extra = si.ExtraSource(
path="third_party/Sub", revision="abc123",
tarball_url="https://example.test/sub.tar.gz", required_path="sub/__init__.py",
)
spec = _mk_spec(extra_sources=(extra,))
urls = []
def fake_stream(method, url, **kw):
urls.append(url)
return _FakeStream(_submodule_tarball() if url == extra.tarball_url
else _tarball_bytes("fake-side-main"))
monkeypatch.setattr(si.shutil, "which", lambda n: None)
monkeypatch.setattr(httpx, "stream", fake_stream)
si._step_fetch_source(spec, si._new_job(spec.engine_id))
sub = si.managed_checkout(spec) / "third_party" / "Sub"
assert (sub / "sub" / "__init__.py").is_file()
assert urls == [spec.tarball_url, extra.tarball_url]
# Present at the pinned revision: nothing is fetched again.
urls.clear()
si._step_fetch_source(spec, si._new_job(spec.engine_id))
assert urls == []
# At another revision: only the submodule is fetched again.
(sub / ".voicestudio_source_revision").write_text("old\n", encoding="utf-8")
si._step_fetch_source(spec, si._new_job(spec.engine_id))
assert urls == [extra.tarball_url]
def test_only_the_listed_weight_files_are_downloaded(monkeypatch):
import huggingface_hub
spec = _mk_spec(
weights_repo_id="org/model", weights_revision="rev1", weights_subdir="w",
weights_config_names=("m.yaml",), weights_allow_patterns=("m.yaml", "llm.pt"),
)
seen = {}
def fake_snapshot(**kw):
seen.update(kw)
w = Path(kw["local_dir"])
w.mkdir(parents=True, exist_ok=True)
(w / "m.yaml").write_text("x")
(w / "llm.pt").write_bytes(b"\0" * (6 * 1024 * 1024))
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot)
monkeypatch.setattr("services.endpoint_race.effective_endpoint", lambda: None)
monkeypatch.setattr("services.token_resolver.resolve", lambda: None)
si._step_fetch_weights(spec, si._new_job(spec.engine_id))
assert seen["allow_patterns"] == ["m.yaml", "llm.pt"]
assert si._weights_present(spec)
def test_weights_from_an_earlier_run_do_not_prove_the_dependencies_finished():
spec = _mk_spec(weights_repo_id="org/model", weights_revision="r", weights_subdir="w",
weights_config_names=("m.yaml",))
checkout = si.managed_checkout(spec)
py = si._venv_python(checkout / ".venv")
py.parent.mkdir(parents=True)
py.write_text("#!fake\n")
(checkout / "pyproject.toml").write_text("[project]\n")
w = checkout / "w"
w.mkdir()
(w / "m.yaml").write_text("x")
(w / "llm.pt").write_bytes(b"\0" * (6 * 1024 * 1024))
(w / si._WEIGHTS_COMPLETE_MARKER).write_text("org/model\nr\n0\n", encoding="utf-8")
assert si._weights_present(spec)
assert not si._healthy(spec)
(checkout / si._INSTALL_COMPLETE_MARKER).write_text("x\n", encoding="utf-8")
assert si._healthy(spec)
# IndexTTS predates the marker and keeps its weights-only check.
assert si.get_spec("indextts2").requires_install_marker is False
def _tarball_with_absolute_symlink() -> bytes:
"""Shaped like the pinned Matcha-TTS tarball: a package plus a `data` link
to a folder on its author's machine."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
data = b"x = 1\n"
info = tarfile.TarInfo("Matcha-abc/matcha/__init__.py")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
link = tarfile.TarInfo("Matcha-abc/data")
link.type = tarfile.SYMTYPE
link.linkname = "/home/someone/Projects/Grad-TTS/data"
tf.addfile(link)
return buf.getvalue()
def test_a_tarball_with_an_absolute_link_still_extracts(monkeypatch, tmp_path):
"""The stdlib "data" filter raises AbsoluteLinkError on such a link, which
aborted the Matcha-TTS fetch and with it the CosyVoice install."""
import httpx
monkeypatch.setattr(httpx, "stream", lambda method, url, **kw: _FakeStream(_tarball_with_absolute_symlink()))
dest = tmp_path / "third_party" / "Matcha-TTS"
dest.parent.mkdir(parents=True)
job = si._new_job("fake-side")
si._download_and_extract(job, "https://example.test/m.tar.gz", dest, tmp_path, "OMNIVOICE_FAKE_SIDE_DIR")
assert (dest / "matcha" / "__init__.py").is_file()
assert not (dest / "data").exists() and not (dest / "data").is_symlink()
@pytest.mark.parametrize("platform", ["win32", "linux"])
def test_a_failed_dependency_install_names_the_windows_path_limit(monkeypatch, platform):
"""openai-whisper (a CosyVoice dependency) builds from source, and under a
long cache path its build fails on Windows' 260-character limit with a
bare "No such file or directory"."""
spec = _mk_spec()
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
monkeypatch.setattr(si, "_run_logged", lambda job, argv, *, timeout, env=None: 1)
monkeypatch.setattr(si.sys, "platform", platform)
with pytest.raises(si._StepError) as err:
si._step_install_deps(spec, si._new_job(spec.engine_id))
assert ("LongPathsEnabled" in err.value.remediation) == (platform == "win32")
@@ -48,6 +48,7 @@ EXPECTED_SIDECARS = {
ENGINES / "pockettts" / "main.py",
ENGINES / "voxcpm2_subprocess" / "main.py",
ENGINES / "moss_tts_nano_subprocess" / "main.py",
ENGINES / "cosyvoice_subprocess" / "main.py",
ENGINES / "supertonic3" / "sidecar.py",
}