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:
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user