Files
diodiogod-TTS-Audio-Suite/install.py
2026-08-11 23:08:35 -03:00

1989 lines
93 KiB
Python

#!/usr/bin/env python3
"""
TTS Audio Suite - ComfyUI Installation Script
Handles Python 3.13 compatibility and dependency conflicts automatically.
This script is called by ComfyUI Manager to install all required dependencies
for the TTS Audio Suite custom node with proper conflict resolution.
NOTE: torchcodec (audio codec library) was removed to eliminate FFmpeg system
dependency. torchaudio.load() works fine with fallback backends (soundfile, scipy).
No quality loss - only negligible audio loading speed difference.
"""
import subprocess
import sys
import os
import platform
import importlib.machinery
import importlib.util
import hashlib
import json
import shutil
import tempfile
from pathlib import Path
from typing import List, Optional
class TTSAudioInstaller:
"""Intelligent installer for TTS Audio Suite with Python 3.13 compatibility"""
# Bump when the install procedure itself gains or changes dependencies.
# This keeps normal ComfyUI patch updates on the fast path.
INSTALL_STATE_VERSION = 2
CORE_MODULE_CHECKS = (
("torch", "PyTorch"),
("torchaudio", "TorchAudio"),
("transformers", "Transformers"),
("soundfile", "SoundFile"),
("numpy", "NumPy"),
("librosa", "Librosa"),
("omegaconf", "OmegaConf"),
)
# These are the external engine runtimes installed or repaired by this
# script. Imports only verify package availability; they never load models.
ENGINE_RUNTIME_CHECKS = (
("VibeVoice", ("vibevoice", "av")),
("Echo-TTS", ("echo_tts",)),
("OmniVoice", ("omnivoice",)),
("Dots TTS", ("dots_tts.runtime",)),
("DramaBox", ("av", "einops", "yaml")),
("Fish Audio S2", ("fish_speech.inference_engine",)),
)
def __init__(self):
self.python_version = sys.version_info
self.is_python_313 = self.python_version >= (3, 13)
self.is_windows = platform.system() == "Windows"
self.is_macos = platform.system() == "Darwin"
self.is_m1_mac = self.is_macos and platform.machine() == "arm64"
self.pip_cmd = [sys.executable, "-m", "pip"]
self.russian_stress_fork_ref = "git+https://github.com/diodiogod/add-stress-to-epub.git@98f53b9"
self.engine_validation_results = {}
self.install_warnings = []
def log(self, message: str, level: str = "INFO"):
"""Log installation progress with safe visual indicators"""
# Use ASCII-safe symbols that work on all systems
symbol_map = {
"INFO": "[i]",
"SUCCESS": "[+]",
"WARNING": "[!]",
"ERROR": "[X]",
"INSTALL": "[*]"
}
symbol = symbol_map.get(level, "[i]")
print(f"{symbol} {message}")
def add_install_warning(self, description: str, detail: str):
"""Record one concise warning without duplicating it in the summary."""
if not any(existing[0] == description for existing in self.install_warnings):
self.install_warnings.append((description, detail))
def ensure_requirements_installed(self):
"""Check and install requirements.txt if needed"""
self.log("Checking requirements.txt dependencies", "INFO")
requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
if not os.path.exists(requirements_path):
self.log("requirements.txt not found - skipping", "WARNING")
return
# Parse requirements.txt to get all package specs and detect both missing and incompatible versions.
packages_to_install = []
package_specs = {}
try:
try:
from importlib.metadata import version, PackageNotFoundError
except ImportError:
from importlib_metadata import version, PackageNotFoundError
try:
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
except Exception:
Requirement = None
canonicalize_name = None
with open(requirements_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
# Remove inline comments
clean_line = line.split('#')[0].strip()
if not clean_line:
continue
# Parse requirement with markers if possible
if Requirement:
try:
req = Requirement(clean_line)
except Exception:
req = None
else:
req = None
if req and req.marker and not req.marker.evaluate():
continue
if req:
package_name = req.name
else:
# Fallback: extract package name (before >= <= == etc.)
package_name = clean_line.split('>=')[0].split('<=')[0].split('==')[0].split('<')[0].split('>')[0].split('!')[0].strip()
if not package_name:
continue
normalized_name = canonicalize_name(package_name) if canonicalize_name else package_name
package_specs[normalized_name] = clean_line
try:
installed_version = version(package_name)
if req and req.specifier and installed_version not in req.specifier:
packages_to_install.append(package_name)
except PackageNotFoundError:
packages_to_install.append(package_name)
except Exception as e:
self.log(f"Error reading requirements.txt: {e}", "WARNING")
return
# Preserve order but avoid duplicate installs if multiple requirement lines resolve to the same package.
deduped_packages_to_install = []
seen = set()
for package in packages_to_install:
normalized_name = canonicalize_name(package) if canonicalize_name else package
if normalized_name in seen:
continue
seen.add(normalized_name)
deduped_packages_to_install.append(package)
if deduped_packages_to_install:
self.log(
f"Found {len(deduped_packages_to_install)} missing/outdated requirements.txt packages: "
f"{', '.join(deduped_packages_to_install[:5])}"
f"{'...' if len(deduped_packages_to_install) > 5 else ''}",
"WARNING"
)
self.log("Installing/upgrading requirements individually (preserves ComfyUI Manager safeguards)", "INFO")
# Install each missing/incompatible package individually using our safe method
for package in deduped_packages_to_install:
normalized_name = canonicalize_name(package) if canonicalize_name else package
package_spec = package_specs.get(normalized_name, package)
self.run_pip_command(["install", package_spec], f"Installing {package}", ignore_errors=True)
else:
self.log("All requirements.txt dependencies already satisfied", "SUCCESS")
def check_system_dependencies(self):
"""Check optional system libraries and provide helpful feature warnings."""
if self.is_windows:
return True # Windows packages come pre-compiled
if self.is_macos:
return self.check_macos_dependencies()
else:
return self.check_linux_dependencies()
def _find_macos_library(self, lib_name):
"""Find library on macOS, checking both system paths and Homebrew installations"""
import ctypes.util
import glob
# First try standard ctypes.util.find_library (works for system libraries)
if ctypes.util.find_library(lib_name):
return True
# Fallback: Check Homebrew installation paths directly
# Apple Silicon Macs: /opt/homebrew/lib
# Intel Macs: /usr/local/lib
homebrew_paths = ['/opt/homebrew/lib', '/usr/local/lib']
for homebrew_path in homebrew_paths:
# Look for .dylib files matching the library name
pattern = f"{homebrew_path}/lib{lib_name}*.dylib"
matches = glob.glob(pattern)
if matches:
return True
return False
def check_macos_dependencies(self):
"""Check for optional system libraries on macOS."""
self.log("Checking macOS system dependencies...", "INFO")
missing_deps = []
# Check for libsamplerate (needed by resampy/soxr)
try:
if not self._find_macos_library('samplerate'):
missing_deps.append(('libsamplerate', 'audio resampling'))
except Exception:
pass
# Check for portaudio (needed for sounddevice)
try:
if not self._find_macos_library('portaudio'):
missing_deps.append(('portaudio', 'voice recording'))
except Exception:
pass
if missing_deps:
self.log("Optional system dependencies are missing", "WARNING")
print("\n" + "="*60)
print("OPTIONAL MACOS SYSTEM DEPENDENCIES")
print("="*60)
for dep, purpose in missing_deps:
print(f"• {dep} (for {purpose})")
print("\nPlease install with Homebrew:")
deps_list = " ".join([dep for dep, _ in missing_deps])
print(f"brew install {deps_list}")
print("\n# If you don't have Homebrew:")
print('/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"')
if self.is_m1_mac:
print("\n# M1/M2 Mac Note:")
print("Make sure you're using an ARM64 Python environment!")
print("Check with: python -c \"import platform; print(platform.machine())\"")
print("Should show: arm64 (not x86_64)")
print("="*60)
print("Core TTS installation will continue; only the listed features may be unavailable.\n")
self.add_install_warning(
"Optional macOS audio libraries",
"Install the listed Homebrew packages to enable every audio feature.",
)
if not missing_deps:
self.log("macOS system dependencies check passed", "SUCCESS")
return True
def check_linux_dependencies(self):
"""Check for optional system libraries on Linux."""
self.log("Checking Linux system dependencies...", "INFO")
missing_deps = []
# Check for libsamplerate (needed by resampy/soxr)
try:
# Try importing a package that would fail if libsamplerate is missing
import ctypes.util
if not ctypes.util.find_library('samplerate'):
missing_deps.append(('libsamplerate0-dev', 'audio resampling'))
except:
pass
# Check for portaudio (needed for sounddevice)
try:
import ctypes.util
if not ctypes.util.find_library('portaudio'):
missing_deps.append(('portaudio19-dev', 'voice recording'))
except:
pass
if missing_deps:
self.log("Optional system dependencies are missing", "WARNING")
print("\n" + "="*60)
print("OPTIONAL LINUX SYSTEM DEPENDENCIES")
print("="*60)
for dep, purpose in missing_deps:
print(f"• {dep} (for {purpose})")
print("\nPlease install with:")
print("# Ubuntu/Debian:")
deps_list = " ".join([dep for dep, _ in missing_deps])
print(f"sudo apt-get install {deps_list}")
print("\n# Fedora/RHEL:")
fedora_packages = {
"libsamplerate0-dev": "libsamplerate-devel",
"portaudio19-dev": "portaudio-devel",
}
fedora_deps = " ".join(fedora_packages.get(dep, dep) for dep, _ in missing_deps)
print(f"sudo dnf install {fedora_deps}")
print("="*60)
print("Core TTS installation will continue; only the listed features may be unavailable.\n")
self.add_install_warning(
"Optional Linux audio libraries",
"Install the listed system packages to enable every audio feature.",
)
if not missing_deps:
self.log("Linux system dependencies check passed", "SUCCESS")
return True
def run_pip_command(self, args: List[str], description: str, ignore_errors: bool = False) -> bool:
"""Execute pip command with error handling and Windows UTF-8 support"""
cmd = self.pip_cmd + args
self.log(f"{description}...", "INSTALL")
# Set UTF-8 encoding for Windows to prevent charmap errors
env = os.environ.copy()
if self.is_windows:
env['PYTHONUTF8'] = '1'
env['PYTHONIOENCODING'] = 'utf-8'
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
env=env,
encoding='utf-8' if self.is_windows else None
)
if result.stdout.strip():
print(result.stdout)
return True
except subprocess.CalledProcessError as e:
if ignore_errors:
error_msg = e.stderr.strip()
if len(error_msg) > 1000:
error_msg = f"...(last 1000 chars)...\n{error_msg[-1000:]}"
self.install_warnings.append((description, error_msg))
self.log(f"Warning: {description} failed (continuing anyway): {error_msg}", "WARNING")
return False
else:
self.log(f"Error: {description} failed: {e.stderr.strip()}", "ERROR")
raise
def detect_cuda_version(self):
"""Detect CUDA version and determine best PyTorch index"""
try:
# Try to detect CUDA version
result = subprocess.run(['nvidia-smi'], capture_output=True, text=True, timeout=10)
if result.returncode == 0 and 'CUDA Version:' in result.stdout:
# Extract CUDA version (e.g., "CUDA Version: 12.1")
import re
cuda_match = re.search(r'CUDA Version:\s*(\d+)\.(\d+)', result.stdout)
if cuda_match:
major, minor = int(cuda_match.group(1)), int(cuda_match.group(2))
self.log(f"Detected CUDA {major}.{minor}", "INFO")
# Choose appropriate PyTorch CUDA build based on detected version
if major == 12 and minor >= 8:
return "cu124" # CUDA 12.8+ → use cu124 index
elif major >= 12:
return "cu121" # CUDA 12.1+ compatible
elif major == 11 and minor >= 8:
return "cu118" # CUDA 11.8+ compatible
else:
self.log(f"CUDA {major}.{minor} detected - may need manual PyTorch installation", "WARNING")
return "cu118" # Fallback for older CUDA
except:
pass
# No CUDA detected - check for AMD GPU (basic detection)
try:
if self.is_windows:
# Windows: check for AMD in device manager output
result = subprocess.run(['wmic', 'path', 'win32_VideoController', 'get', 'name'],
capture_output=True, text=True, timeout=5)
if 'amd' in result.stdout.lower() or 'radeon' in result.stdout.lower():
self.log("AMD GPU detected - will install CPU version (ROCm not yet supported)", "WARNING")
return "cpu"
except:
pass
self.log("No CUDA detected - installing CPU-only PyTorch", "WARNING")
return "cpu"
def check_pytorch_compatibility(self):
"""Check if current PyTorch meets version and CUDA requirements"""
try:
import torch
current_version = torch.__version__
# Parse version (e.g., "2.5.1+cu124" -> (2, 5, 1))
# Strip CUDA suffix first to avoid parsing errors
import re
clean_version = current_version.split('+')[0] # Remove CUDA suffix like "+cu124"
version_match = re.match(r'(\d+)\.(\d+)\.(\d+)', clean_version)
if version_match:
major, minor, patch = map(int, version_match.groups())
version_tuple = (major, minor, patch)
# Check CUDA availability if we detected CUDA
cuda_available = torch.cuda.is_available()
detected_cuda = self.detect_cuda_version() != "cpu"
# If CUDA mismatch, we need to reinstall
if detected_cuda and not cuda_available:
self.log(f"PyTorch {current_version} found but no CUDA support - will reinstall with CUDA", "WARNING")
return False
elif not detected_cuda and cuda_available:
self.log(f"PyTorch {current_version} has unnecessary CUDA support - keeping anyway", "INFO")
return True
# Check security requirement: 2.6.0+ preferred for CVE-2025-32434, but 2.5.1+ acceptable
if version_tuple >= (2, 6, 0):
self.log(f"PyTorch {current_version} >= 2.6.0 - secure version, skipping installation", "SUCCESS")
return True
elif version_tuple >= (2, 5, 1):
# 2.5.1+ is acceptable but try to upgrade to 2.6.0 if available
self.log(f"PyTorch {current_version} >= 2.5.1 - will attempt upgrade to 2.6.0 for security fix", "INFO")
return False # Try upgrade, but won't fail if 2.6.0 unavailable
else:
self.log(f"PyTorch {current_version} < 2.5.1 - will upgrade for security and compatibility", "WARNING")
return False
else:
self.log(f"Could not parse PyTorch version: {current_version} - will reinstall", "WARNING")
return False
except ImportError:
self.log("PyTorch not found - will install", "INFO")
return False
except Exception as e:
self.log(f"Error checking PyTorch: {e} - will reinstall", "WARNING")
return False
def install_pytorch_with_cuda(self):
"""Install PyTorch with appropriate acceleration (2.6+ required for CVE-2025-32434 security fix)"""
# Check if current PyTorch is already compatible
if self.check_pytorch_compatibility():
return # Skip installation
cuda_version = self.detect_cuda_version()
if cuda_version == "cpu":
self.log("Installing PyTorch 2.6+ (CPU-only)", "INFO")
index_url = "https://download.pytorch.org/whl/cpu"
else:
self.log(f"Installing PyTorch 2.6+ with CUDA {cuda_version} support", "INFO")
index_url = f"https://download.pytorch.org/whl/{cuda_version}"
# Force uninstall if we need to switch between CPU/CUDA variants
try:
import torch
current_version = torch.__version__
if (cuda_version != "cpu" and not torch.cuda.is_available()) or \
(cuda_version == "cpu" and torch.cuda.is_available()):
self.log(f"Uninstalling existing PyTorch {current_version} to switch variants", "WARNING")
uninstall_cmd = ["uninstall", "-y", "torch", "torchvision", "torchaudio"]
self.run_pip_command(uninstall_cmd, "Uninstalling existing PyTorch")
except ImportError:
pass # PyTorch not installed
# Try PyTorch 2.6+ first, fallback to 2.5+ if unavailable
try:
if cuda_version == "cpu":
pytorch_packages_26 = [
"torch>=2.6.0",
"torchvision",
"torchaudio>=2.6.0"
]
else:
pytorch_packages_26 = [
"torch>=2.6.0",
"torchvision",
"torchaudio>=2.6.0"
]
pytorch_cmd_26 = [
"install",
"--upgrade",
"--force-reinstall"
] + pytorch_packages_26 + [
"--index-url", index_url
]
self.run_pip_command(pytorch_cmd_26, f"Installing PyTorch 2.6+ ({cuda_version} support)")
except subprocess.CalledProcessError:
# PyTorch 2.6.0 not available for this CUDA version - try 2.5+
self.log(f"PyTorch 2.6.0 not available for {cuda_version} - falling back to latest 2.5.x", "WARNING")
if cuda_version == "cpu":
pytorch_packages_25 = [
"torch>=2.5.0",
"torchvision",
"torchaudio>=2.5.0"
]
else:
pytorch_packages_25 = [
"torch>=2.5.0",
"torchvision",
"torchaudio>=2.5.0"
]
pytorch_cmd_25 = [
"install",
"--upgrade",
"--force-reinstall"
] + pytorch_packages_25 + [
"--index-url", index_url
]
self.run_pip_command(pytorch_cmd_25, f"Installing PyTorch 2.5+ ({cuda_version} support)")
def module_available(self, module_name: str) -> bool:
"""Check module presence without starting another Python process or importing it."""
try:
parts = module_name.split(".")
spec = importlib.util.find_spec(parts[0])
if spec is None:
return False
# util.find_spec() imports the parent when given a dotted name.
# Walk the package paths directly so presence checks stay side-effect free.
for index in range(1, len(parts)):
search_locations = spec.submodule_search_locations
if search_locations is None:
return False
qualified_name = ".".join(parts[: index + 1])
spec = importlib.machinery.PathFinder.find_spec(
qualified_name,
search_locations,
)
if spec is None:
return False
return True
except (ImportError, ModuleNotFoundError, AttributeError, ValueError):
return False
def _install_state_path(self) -> Path:
"""Return the ignored, per-node state file used for idempotent updates."""
return Path(__file__).resolve().parent / ".cache" / "install_state.json"
def _install_state_fingerprint(self) -> str:
"""Fingerprint installer logic and the active dependency environment."""
digest = hashlib.sha256()
digest.update(str(self.INSTALL_STATE_VERSION).encode("utf-8"))
digest.update(str(sys.executable).lower().encode("utf-8"))
digest.update(f"{self.python_version.major}.{self.python_version.minor}".encode("utf-8"))
digest.update(repr(self.ENGINE_RUNTIME_CHECKS).encode("utf-8"))
# An installer fix must be able to invalidate an old successful state
# without requiring users to know an environment variable.
digest.update(Path(__file__).resolve().read_bytes())
requirements_path = Path(__file__).resolve().parent / "requirements.txt"
if requirements_path.exists():
digest.update(requirements_path.read_bytes())
try:
from importlib.metadata import PackageNotFoundError, version
import re
for line in requirements_path.read_text(encoding="utf-8").splitlines():
requirement = line.split("#", 1)[0].strip()
if not requirement:
continue
package_name = re.split(r"[<>=!~;\s]", requirement, maxsplit=1)[0]
try:
installed_version = version(package_name)
except PackageNotFoundError:
installed_version = "<missing>"
digest.update(f"{package_name}={installed_version}".encode("utf-8"))
except (OSError, ImportError):
digest.update(b"<dependency-version-scan-unavailable>")
return digest.hexdigest()
def _quick_installation_status(self):
"""Check the small set of entry points needed to trust the install marker."""
missing = []
for module_name, display_name in self.CORE_MODULE_CHECKS:
if not self.module_available(module_name):
missing.append(display_name)
self.engine_validation_results = {}
for display_name, module_names in self.ENGINE_RUNTIME_CHECKS:
available = all(self.module_available(module_name) for module_name in module_names)
self.engine_validation_results[display_name] = available
if not available:
missing.append(display_name)
return missing
def can_skip_dependency_installation(self) -> bool:
"""Avoid rerunning pip and engine setup when this environment is unchanged."""
if os.environ.get("TTS_AUDIO_SUITE_FORCE_INSTALL") == "1":
self.log("Forced installation requested by TTS_AUDIO_SUITE_FORCE_INSTALL", "WARNING")
return False
state_path = self._install_state_path()
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except (FileNotFoundError, OSError, json.JSONDecodeError):
return False
if state.get("fingerprint") != self._install_state_fingerprint():
return False
missing = self._quick_installation_status()
if missing:
self.log(
f"Install state found, but runtime checks need repair: {', '.join(missing)}",
"WARNING",
)
return False
self.log("Dependencies unchanged and runtime entry points are present - skipping package installation", "SUCCESS")
return True
def save_installation_state(self, installation_valid: bool):
"""Persist only a successful dependency state for future fast updates."""
if not installation_valid:
return
state_path = self._install_state_path()
try:
state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(
json.dumps(
{
"fingerprint": self._install_state_fingerprint(),
"python": sys.executable,
"engine_runtimes": list(self.engine_validation_results),
},
indent=2,
),
encoding="utf-8",
)
except OSError as error:
self.log(f"Could not save installer state (continuing): {error}", "WARNING")
def verify_faiss_installation(self) -> bool:
"""Verify FAISS imports and exposes the CPU APIs required by RVC index building."""
probe = (
"import json\n"
"import faiss\n"
"details = {\n"
" 'file': getattr(faiss, '__file__', None),\n"
" 'loader': type(getattr(faiss, '__loader__', None)).__name__ if getattr(faiss, '__loader__', None) else None,\n"
" 'has_index_factory': hasattr(faiss, 'index_factory'),\n"
" 'has_write_index': hasattr(faiss, 'write_index'),\n"
" 'has_read_index': hasattr(faiss, 'read_index'),\n"
"}\n"
"missing = [name for name in ('index_factory', 'write_index', 'read_index') if not hasattr(faiss, name)]\n"
"if missing:\n"
" raise RuntimeError(f\"faiss imported but missing required API: {', ' .join(missing)} | details={details}\")\n"
"print(json.dumps(details))\n"
)
try:
result = subprocess.run(
[sys.executable, "-c", probe],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
details = (result.stdout or "").strip()
if details:
self.log(f"FAISS verification passed: {details}", "SUCCESS")
return True
error_text = (result.stderr or result.stdout or "unknown faiss failure").strip()
self.log(f"FAISS verification failed: {error_text}", "WARNING")
return False
except Exception as e:
self.log(f"FAISS verification failed: {e}", "WARNING")
return False
def cleanup_stale_faiss_namespace(self) -> None:
"""Remove clearly broken namespace-only faiss directories left behind by bad installs."""
probe = (
"import json, shutil, site, sysconfig\n"
"from pathlib import Path\n"
"roots = set()\n"
"for base in list(site.getsitepackages()) + [site.getusersitepackages(), sysconfig.get_paths().get('purelib'), sysconfig.get_paths().get('platlib')]:\n"
" if base:\n"
" roots.add(base)\n"
"removed = []\n"
"for root in roots:\n"
" candidate = Path(root) / 'faiss'\n"
" if not candidate.is_dir():\n"
" continue\n"
" has_init = (candidate / '__init__.py').exists()\n"
" has_binary = any(candidate.glob('*.so')) or any(candidate.glob('*.pyd')) or any(candidate.glob('*.dll')) or any(candidate.glob('*.dylib'))\n"
" if not has_init and not has_binary:\n"
" shutil.rmtree(candidate, ignore_errors=True)\n"
" removed.append(str(candidate))\n"
"print(json.dumps(removed))\n"
)
try:
result = subprocess.run(
[sys.executable, "-c", probe],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
error_text = (result.stderr or result.stdout or "unknown cleanup failure").strip()
self.log(f"FAISS namespace cleanup failed: {error_text}", "WARNING")
return
removed = (result.stdout or "[]").strip()
if removed and removed != "[]":
self.log(f"Removed stale namespace-only faiss package directories: {removed}", "WARNING")
except Exception as e:
self.log(f"FAISS namespace cleanup failed: {e}", "WARNING")
def check_package_installed(self, package_spec):
"""Check if a package meets the version requirement"""
try:
# Parse package specification (e.g., "transformers>=4.46.3")
import re
match = re.match(r'^([a-zA-Z0-9\-_]+)([><=!]+)?(.+)?$', package_spec)
if not match:
return False
package_name = match.group(1)
operator = match.group(2) if match.group(2) else None
required_version = match.group(3) if match.group(3) else None
# Use modern importlib.metadata (Python 3.8+) with fallback
try:
from importlib.metadata import version, PackageNotFoundError
except ImportError:
# Fallback for Python < 3.8
try:
from importlib_metadata import version, PackageNotFoundError
except ImportError:
# Final fallback to pkg_resources (with warning suppression)
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
import pkg_resources
try:
distribution = pkg_resources.get_distribution(package_name)
installed_version = distribution.version
if not operator or not required_version:
return True
requirement = pkg_resources.Requirement.parse(package_spec)
return distribution in requirement
except pkg_resources.DistributionNotFound:
return False
try:
installed_version = version(package_name)
if not operator or not required_version:
return True
# Check version requirement using packaging module if available
try:
from packaging.specifiers import SpecifierSet
from packaging.version import Version
spec = SpecifierSet(f"{operator}{required_version}")
return Version(installed_version) in spec
except ImportError:
# Simple version comparison fallback
if operator == ">=":
return installed_version >= required_version
elif operator == ">":
return installed_version > required_version
elif operator == "==":
return installed_version == required_version
elif operator == "<=":
return installed_version <= required_version
elif operator == "<":
return installed_version < required_version
return True
except PackageNotFoundError:
return False
except Exception:
return False
def has_onnxruntime_provider(self):
"""Return True when either CPU or GPU ONNX Runtime is already installed."""
return (
self.check_package_installed("onnxruntime-gpu>=1.19.0")
or self.check_package_installed("onnxruntime>=1.17.0")
)
def install_macos_specific_packages(self):
"""Install packages with Mac-specific requirements"""
if not self.is_macos:
return
self.log("Installing macOS-specific audio packages", "INFO")
# For M1 Macs, ensure we use compatible versions
if self.is_m1_mac:
self.log("M1 Mac detected - installing ARM64-compatible packages", "INFO")
# Force reinstall samplerate with proper architecture
self.run_pip_command(
["uninstall", "-y", "samplerate"],
"Removing potentially x86_64 samplerate package",
ignore_errors=True
)
# Install with --no-cache to force ARM64 build
self.run_pip_command(
["install", "--no-cache-dir", "--force-reinstall", "samplerate>=0.2.1"],
"Installing ARM64-compatible samplerate package"
)
# Install/reinstall audio packages that commonly have architecture issues on Mac
mac_audio_packages = [
"soundfile>=0.12.0",
"sounddevice>=0.4.0",
"pydub",
"webdataset",
]
for package in mac_audio_packages:
self.run_pip_command(
["install", "--force-reinstall", "--no-cache-dir", package],
f"Reinstalling {package} for macOS compatibility"
)
def install_core_dependencies(self):
"""Install safe core dependencies that don't cause conflicts"""
self.log("Checking and installing core dependencies (with smart checking)", "INFO")
core_packages = [
# Audio and basic utilities (PyTorch installed separately with CUDA)
"soundfile>=0.12.0",
"sounddevice>=0.4.0",
# Text processing (safe)
"jieba",
"pypinyin",
"unidecode",
"omegaconf>=2.3.0",
"transformers>=5.3.0,<6.0.0", # Main environment for Higgs Audio v3 / Step Audio EditX / MOSS-TTS. Legacy T4 engines use isolated runtimes.
# Bundled engine dependencies (safe)
"conformer>=0.3.2", # ChatterBox engine
"x-transformers", # F5-TTS engine
"torchdiffeq", # F5-TTS differential equations
"wandb", # F5-TTS logging
"accelerate", # F5-TTS acceleration
"ema-pytorch", # F5-TTS exponential moving average
"datasets", # F5-TTS dataset loading
"vocos", # F5-TTS vocoder
# Basic utilities (safe)
"requests",
"dacite",
"safetensors>=0.6.2", # Required by MOSS-TTS HF checkpoints
"orjson>=3.11.0", # Required by MOSS-TTS remote code
"tiktoken>=0.12.0", # Required by MOSS-TTS tokenizer
"fugashi>=1.4.0", # IndexTTS-2.5 Japanese G2P
"unidic-lite>=1.0.8", # IndexTTS-2.5 Japanese dictionary
# NOTE: opencv-python and pillow installed via install_problematic_packages() with --no-deps
# to prevent forced numpy/pillow downgrades
# SAFE packages from DEPENDENCY_TESTING_RESULTS.md
"s3tokenizer>=0.1.7", # SAFE - Heavy dependencies but NO conflicts
"vector-quantize-pytorch", # SAFE - Clean install
"resemble-perth", # SAFE - Works in ChatterBox
"diffusers>=0.30.0", # SAFE - Likely safe
# "audio-separator>=0.35.2", # MOVED - Requires numpy>=2, installed conditionally
"hydra-core>=1.3.0", # SAFE - Clean install, minimal dependencies
# Dependencies for --no-deps packages based on PyPI metadata
# For librosa (when installed with --no-deps)
"lazy_loader>=0.1", # Required by librosa
"msgpack>=1.0", # Required by librosa
"pooch>=1.1", # Required by librosa
"soxr>=0.3.2", # Required by librosa
"typing_extensions>=4.1.1", # Required by librosa
"decorator>=4.3.0", # Required by librosa
"joblib>=1.0", # Required by librosa
# For VibeVoice (when installed with --no-deps) - only safe dependencies
"ml-collections", # Required by VibeVoice
"absl-py", # Required by VibeVoice (Google's Python utilities)
# NOTE: gradio installed via install_problematic_packages() with --no-deps
# to prevent forced pydantic/pydantic-core downgrades
"av", # Required by VibeVoice (PyAV - audio/video processing)
"scikit-learn>=1.1.0", # Required by librosa
# For cached-path (when installed with --no-deps)
"filelock>=3.4", # Required by cached-path
"rich>=12.1", # Required by cached-path
"boto3", # Required by cached-path
"google-cloud-storage", # Required by cached-path for F5-TTS
"huggingface-hub", # Required by cached-path
# For descript-audio-codec (when installed with --no-deps)
"einops", # Required by descript-audio-codec and MelBandRoFormer
"argbind>=0.3.7", # Required by descript-audio-codec
# NOTE: descript-audiotools causes protobuf conflicts, installed via --no-deps
# For MelBandRoFormer vocal separation
"rotary_embedding_torch", # Required by MelBandRoFormer architecture
# For F5-TTS engine
"matplotlib", # Required by F5-TTS utils_infer.py
# Additional librosa dependencies for --no-deps installation
"audioread>=2.1.9", # Required by librosa
"threadpoolctl>=3.1.0", # Required by scikit-learn for librosa
# Missing descript-audiotools dependencies for --no-deps installation
"flatten-dict", # Required by descript-audiotools
"ffmpy", # Required by descript-audiotools
"importlib-resources", # Required by descript-audiotools
"randomname", # Required by descript-audiotools
"markdown2", # Required by descript-audiotools
"pyloudnorm", # Required by descript-audiotools
"pystoi", # Required by descript-audiotools
"torch-stoi", # Required by descript-audiotools
"ipython", # Required by descript-audiotools
"tensorboard", # Required by descript-audiotools
"julius", # Required by descript-audiotools for DSP operations
# IndexTTS-2 engine dependencies (tested safe - no conflicts found)
"cn2an>=0.5.22", # Chinese number to Arabic number conversion
"g2p-en>=2.1.0", # English grapheme-to-phoneme conversion
"json5>=0.12.0", # JSON5 parsing for IndexTTS-2 config files
"keras>=2.9.0", # Deep learning framework
"modelscope>=1.27.0", # Chinese model hub for IndexTTS-2
"munch>=4.0.0", # Dictionary access with dot notation
"sentencepiece>=0.2.1", # Text tokenization
"textstat>=0.7.10", # Text statistics and readability
]
# Smart installation: check before installing (preserving all original packages and comments)
packages_to_install = []
skipped_packages = []
for package in core_packages:
if self.check_package_installed(package):
package_name = package.split('>=')[0].split('==')[0].split('<')[0]
skipped_packages.append(package_name)
else:
packages_to_install.append(package)
if skipped_packages:
self.log(f"Already satisfied: {', '.join(skipped_packages[:5])}" +
(f" and {len(skipped_packages)-5} others" if len(skipped_packages) > 5 else ""), "SUCCESS")
if packages_to_install:
self.log(f"Installing {len(packages_to_install)} missing core packages", "INFO")
for package in packages_to_install:
self.run_pip_command(["install", package], f"Installing {package}")
else:
self.log("All core dependencies already satisfied", "SUCCESS")
def install_rvc_dependencies(self):
"""Install RVC voice conversion dependencies with smart GPU detection"""
self.log("Installing RVC voice conversion dependencies", "INFO")
# Install core RVC dependency first (with graceful failure for build-tool-less systems)
if self.check_package_installed("monotonic-alignment-search"):
self.log("monotonic-alignment-search already satisfied - skipping", "SUCCESS")
else:
self.run_pip_command(["install", "monotonic-alignment-search"], "Installing monotonic-alignment-search", ignore_errors=True)
# RVC index building only uses the CPU FAISS Python API.
# If users already have a working FAISS module (including a valid GPU build), keep it.
if self.verify_faiss_installation():
self.log("Working FAISS API already available - skipping FAISS install", "SUCCESS")
return
self.log(
"Installing faiss-cpu for RVC voice matching (GPU FAISS is not required for current RVC index building)",
"INFO",
)
# Broken Arch/Linux installs have shown namespace-only 'faiss' stubs and half-installed GPU wheels.
self.run_pip_command(
["uninstall", "-y", "faiss", "faiss-cpu", "faiss-gpu", "faiss-gpu-cu11", "faiss-gpu-cu12"],
"Removing broken or conflicting FAISS packages",
ignore_errors=True,
)
self.cleanup_stale_faiss_namespace()
self.run_pip_command(
["install", "--force-reinstall", "--no-cache-dir", "--no-deps", "faiss-cpu>=1.7.4"],
"Installing faiss-cpu for RVC voice matching (--no-deps)",
)
if not self.verify_faiss_installation():
self.log(
"faiss-cpu installation completed but working FAISS APIs are still unavailable. "
"This usually means a stale site-packages/faiss directory or a broken wheel in the environment.",
"WARNING",
)
def install_numpy_with_constraints(self):
"""Install numpy with version constraints for compatibility"""
self.log("Checking numpy compatibility", "INFO")
# Python 3.13+ requires numpy 2.1.0 or newer (no wheels for 1.26.x)
if self.python_version >= (3, 13):
minimum_numpy = "numpy>=2.1.0,<2.3.0"
self.log("Python 3.13+ detected - requires NumPy 2.1.0 or newer", "INFO")
else:
minimum_numpy = "numpy>=1.26.4,<2.3.0"
# Check if numpy is already installed and what version
try:
import numpy
numpy_version = numpy.__version__
self.log(f"Current numpy version: {numpy_version}", "INFO")
# Parse numpy version
import re
version_match = re.match(r'(\d+)\.(\d+)', numpy_version)
if version_match:
major, minor = int(version_match.group(1)), int(version_match.group(2))
# Python 3.13+ specific check
if self.python_version >= (3, 13) and major < 2:
self.log(f"NumPy {numpy_version} is incompatible with Python 3.13+", "ERROR")
self.log("Python 3.13 requires NumPy 2.1.0 or newer (no wheels for 1.26.x)", "WARNING")
numpy_constraint = minimum_numpy
# Accept both numpy 1.26.x and 2.x.x (but not 2.3.x) for older Python
elif major == 1 and minor >= 26 and self.python_version < (3, 13):
self.log(f"NumPy {numpy_version} is compatible - keeping current version", "INFO")
return # NumPy 1.26.x is fine for Python < 3.13
elif major == 2 and minor < 3:
self.log(f"NumPy {numpy_version} is compatible - keeping current version", "INFO")
return # NumPy 2.0.x, 2.1.x, 2.2.x are fine
elif major >= 2 and minor >= 3:
# Only numpy 2.3+ needs downgrading
self.log(f"NumPy {numpy_version} may cause issues - constraining to <2.3.0", "WARNING")
numpy_constraint = minimum_numpy
else:
# Very old numpy needs updating
self.log(f"NumPy {numpy_version} is too old", "WARNING")
numpy_constraint = minimum_numpy
else:
# Can't parse version - install safe range
numpy_constraint = minimum_numpy
except ImportError:
# No numpy installed - install with appropriate constraints
self.log("NumPy not found - installing with appropriate constraints", "INFO")
numpy_constraint = minimum_numpy
except Exception as e:
# NumPy import failed
self.log(f"NumPy check failed ({e}) - installing safe version", "WARNING")
numpy_constraint = minimum_numpy
# Only install/upgrade numpy if needed
if 'numpy_constraint' in locals():
self.run_pip_command(["install", numpy_constraint], "Installing numpy with version constraints")
# Note: We're not forcing numba installation anymore since we disable JIT anyway
self.log("NumPy compatibility check complete", "INFO")
def install_audio_separator_if_compatible(self):
"""Install audio-separator only if numpy version supports it"""
try:
import numpy
numpy_version = numpy.__version__
# Parse numpy version
import re
version_match = re.match(r'(\d+)\.(\d+)', numpy_version)
if version_match:
major, minor = int(version_match.group(1)), int(version_match.group(2))
if major >= 2:
# NumPy 2.x can use audio-separator
if self.check_package_installed("audio-separator>=0.35.2"):
self.log("audio-separator already satisfied - skipping", "SUCCESS")
else:
self.log(f"NumPy {numpy_version} supports audio-separator - installing", "INFO")
self.run_pip_command(
["install", "audio-separator>=0.35.2"],
"Installing audio-separator for enhanced vocal removal",
ignore_errors=True # It's optional, so don't fail if it doesn't install
)
else:
# NumPy 1.x - skip audio-separator, will use bundled implementations
self.log(f"NumPy {numpy_version} detected - skipping audio-separator (will use bundled vocal removal)", "INFO")
self.log("Vocal removal will use bundled RVC/MelBand/MDX23C implementations", "INFO")
else:
# Can't determine version - skip to be safe
self.log("Could not determine NumPy version - skipping audio-separator", "WARNING")
except ImportError:
# NumPy not installed? This shouldn't happen at this point
self.log("NumPy not found - skipping audio-separator installation", "WARNING")
except Exception as e:
# Any other error - skip audio-separator
self.log(f"Error checking NumPy compatibility for audio-separator: {e}", "WARNING")
self.log("Skipping audio-separator - vocal removal will use bundled implementations", "INFO")
def install_gradio_and_opencv_dependencies(self):
"""Pre-install safe dependencies for gradio and opencv-python"""
self.log("Pre-installing safe dependencies for gradio and opencv-python", "INFO")
# Only pre-install packages that won't be downgraded
# pydantic and pydantic-core MUST be installed by gradio itself (they have version requirements)
# pillow is safe to pre-install with flexible version range
gradio_safe_deps = [
"fastapi<1.0,>=0.115.2", # FastAPI for gradio server
"starlette<0.50.0,>=0.40.0", # Web framework (starlette 0.50.x+)
"gradio-client==1.13.3", # Specific version for gradio compatibility
"websockets<16.0,>=13.0", # WebSocket support
"python-multipart>=0.0.18", # Form parsing
"uvicorn>=0.14.0", # ASGI server
"annotated-doc>=0.0.2", # FastAPI dependency
]
# Install safe dependencies - these won't cause downgrades
for dep in gradio_safe_deps:
if self.check_package_installed(dep):
self.log(f"Pre-installing: {dep} (already satisfied)", "SUCCESS")
continue
self.run_pip_command(
["install", dep],
f"Pre-installing: {dep}",
ignore_errors=True
)
# NOTE: pydantic and pydantic-core are NOT pre-installed here
# Gradio requires specific versions (pydantic<2.12, pydantic-core==2.33.2)
# These will be installed when gradio installs with --no-deps
# This is the trade-off: some packages will be downgraded, but only to versions
# that are compatible with the software that needs them
def install_onnxruntime_with_gpu_support(self):
"""Install ONNX Runtime with GPU acceleration if CUDA is available"""
self.log("Installing ONNX Runtime (OpenSeeFace, Step Audio EditX)", "INFO")
if self.has_onnxruntime_provider():
if self.check_package_installed("onnxruntime-gpu>=1.19.0"):
self.log("Existing onnxruntime-gpu detected - keeping GPU runtime", "SUCCESS")
else:
self.log("Existing onnxruntime detected - skipping install", "SUCCESS")
return
cuda_version = self.detect_cuda_version()
# Try GPU version first if CUDA is available
if cuda_version != "cpu":
if self.check_package_installed("onnxruntime-gpu>=1.19.0"):
self.log("onnxruntime-gpu already satisfied - skipping", "SUCCESS")
return
self.log("CUDA detected - attempting onnxruntime-gpu for GPU acceleration", "INFO")
try:
# Try GPU version without --no-deps (modern versions don't force numpy downgrade)
# Relaxed constraint to >=1.19.0 to avoid forcing upgrades from 1.22.0
gpu_success = self.run_pip_command(
["install", "onnxruntime-gpu>=1.19.0"],
"Installing onnxruntime-gpu (GPU acceleration for ONNX models)",
ignore_errors=True
)
if gpu_success:
# Trust that onnxruntime-gpu installation succeeded
# Note: CUDA provider verification can fail in restricted environments (Docker, containers)
# even when onnxruntime-gpu works fine at runtime. Installing both CPU and GPU versions
# causes Python to prefer the CPU version, degrading performance.
self.log("onnxruntime-gpu installed successfully", "SUCCESS")
return
except subprocess.CalledProcessError:
self.log("onnxruntime-gpu installation failed - falling back to CPU version", "WARNING")
# Fallback to CPU version
if self.check_package_installed("onnxruntime>=1.17.0"):
self.log("onnxruntime already satisfied - skipping", "SUCCESS")
else:
self.log("Installing onnxruntime (CPU) for OpenSeeFace and Step Audio EditX", "INFO")
self.run_pip_command(
["install", "onnxruntime>=1.17.0"],
"Installing onnxruntime (CPU version)",
ignore_errors=True
)
def install_problematic_packages(self):
"""Install packages that cause conflicts using --no-deps"""
self.log("Installing problematic packages with --no-deps to prevent conflicts", "WARNING")
problematic_packages = [
"librosa", # Forces numpy downgrade - compatibility handled by runtime numba disabling for Python 3.13
"descript-audio-codec", # Pulls unnecessary deps, conflicts with protobuf
"descript-audiotools", # Forces protobuf downgrade from 6.x to 3.19.x
"cached-path", # Forces package downgrades
"omnivoice", # Keep shared dependency resolution under our control
"torchcrepe", # Conflicts via librosa dependency
# NOTE: onnxruntime moved to install_onnxruntime_with_gpu_support() for smart GPU detection
"opencv-python", # Forces numpy downgrade from 2.x to 1.26.x - dependencies pre-installed
"gradio", # Forces pydantic, pillow, pydantic-core downgrades - dependencies pre-installed
]
for package in problematic_packages:
if self.check_package_installed(package):
self.log(f"{package} already satisfied - skipping", "SUCCESS")
continue
self.run_pip_command(
["install", package, "--no-deps"],
f"Installing {package} (--no-deps)",
ignore_errors=True # Some may already be satisfied
)
def install_vibevoice(self):
"""Install VibeVoice with careful dependency management"""
self.log("Installing VibeVoice TTS engine", "INFO")
# First ensure critical dependencies that VibeVoice needs but might downgrade
# Handle 'av' (PyAV) dependency specifically - frequent failure point on Windows
self.log("Checking VibeVoice 'av' dependency...", "INFO")
if self.check_package_installed("av"):
av_success = True
self.log("av already satisfied - skipping", "SUCCESS")
else:
# Try installing av first (often fails on Windows without Build Tools)
av_success = self.run_pip_command(["install", "av"], "Installing av (PyAV)", ignore_errors=True)
if not av_success:
self.log("Failed to install 'av' (PyAV) - VibeVoice may not work", "WARNING")
if self.is_windows:
self.log("Windows Fix: Install Visual C++ Build Tools or use a compatible Python version", "INFO")
self.log("This is often due to missing pre-built wheels for your Python version", "INFO")
# Now install VibeVoice with --no-deps to prevent downgrades
# NOTE: Using FushionHub fork temporarily - Microsoft removed the official repo
# Original: https://github.com/microsoft/VibeVoice.git (no longer exists)
# This fork maintains the same API and should work identically
self.log("Installing VibeVoice with --no-deps to prevent package downgrades", "WARNING")
if self.check_package_installed("vibevoice"):
self.log("VibeVoice already satisfied - skipping", "SUCCESS")
else:
self.run_pip_command(
["install", "git+https://github.com/FushionHub/VibeVoice.git", "--no-deps"],
"Installing VibeVoice (--no-deps)",
ignore_errors=True
)
def install_echo_tts(self):
"""Install Echo-TTS with minimal dependency impact"""
self.log("Installing Echo-TTS engine", "INFO")
if self.check_package_installed("echo-tts"):
self.log("echo-tts already satisfied - skipping", "SUCCESS")
return
installed = self.run_pip_command(
["install", "echo-tts", "--no-deps"],
"Installing Echo-TTS (--no-deps)",
ignore_errors=True
)
if not installed:
self.log("Echo-TTS pip install failed - trying GitHub source", "WARNING")
self.run_pip_command(
["install", "git+https://github.com/jordandare/echo-tts.git", "--no-deps"],
"Installing Echo-TTS from GitHub (--no-deps)",
ignore_errors=True
)
def install_dots_tts(self):
"""Install official Dots TTS with minimal dependency impact."""
self.log("Installing Dots TTS engine", "INFO")
dependency_probes = [
("lingua", ["install", "lingua-language-detector"], "Installing lingua-language-detector"),
("langcodes", ["install", "langcodes"], "Installing langcodes"),
("loguru", ["install", "loguru"], "Installing loguru"),
("tn.chinese.normalizer", ["install", "WeTextProcessing"], "Installing WeTextProcessing"),
]
for module_name, pip_args, description in dependency_probes:
if self.module_available(module_name):
self.log(f"{module_name} import already satisfied - skipping", "SUCCESS")
continue
if module_name == "tn.chinese.normalizer" and self.is_windows:
self.log(
"WeTextProcessing/tn is unavailable on Windows; skipping the known pynini source build",
"WARNING",
)
continue
self.run_pip_command(pip_args, description, ignore_errors=True)
if self.module_available("dots_tts.runtime") and self.module_available("tn.chinese.normalizer"):
self.log("dots_tts.runtime already satisfied - skipping", "SUCCESS")
return
if self.check_package_installed("dots.tts") and self.module_available("dots_tts.runtime"):
self.log(
"Dots package is installed but its tn/WeTextProcessing prerequisite is unavailable; skipping rebuild",
"WARNING",
)
return
dots_install_args = [
"install",
"git+https://github.com/rednote-hilab/dots.tts.git",
"--no-deps",
]
if self.is_python_313:
# Dots currently declares <3.13, although its source works in the
# suite's tested Python 3.13 environment. Keep the existing stack
# and bypass only pip's metadata gate.
dots_install_args.insert(1, "--ignore-requires-python")
self.log(
"Python 3.13 detected - allowing the tested Dots source install despite its package metadata",
"WARNING",
)
self.run_pip_command(
dots_install_args,
"Installing official dots.tts from GitHub (--no-deps)",
ignore_errors=True
)
def install_fish_audio_s2(self):
"""Install Fish S2 without changing the suite's core ML stack."""
self.log("Installing Fish Audio S2 support in the main T5 environment", "INFO")
# The adapter uses Fish's official inference_engine API. Pin the tested
# S2 release because distribution metadata alone cannot verify that API.
fish_repository = "https://github.com/fishaudio/fish-speech.git"
fish_revision = "v2.0.0-beta"
fish_runtime_module = "fish_speech.inference_engine"
packages = [
("lightning", "lightning"),
("pytorch_lightning", "pytorch-lightning"),
("lightning_utilities", "lightning-utilities"),
("torchmetrics", "torchmetrics"),
("opencc", "opencc-python-reimplemented==0.1.7"),
("ormsgpack", "ormsgpack"),
("zstandard", "zstandard"),
("pyrootutils", "pyrootutils"),
("loralib", "loralib"),
]
for module_name, package_name in packages:
if self.module_available(module_name):
self.log(f"{module_name} import already satisfied - skipping", "SUCCESS")
continue
self.run_pip_command(
["install", package_name, "--no-deps"],
f"Installing Fish S2 dependency {package_name} (--no-deps)",
ignore_errors=True,
)
if self.module_available(fish_runtime_module):
self.log("Fish S2 inference runtime already satisfied - skipping", "SUCCESS")
return
if self.check_package_installed("fish-speech"):
self.log(
"fish-speech distribution is installed but the required Fish S2 inference runtime is missing - repairing",
"WARNING",
)
# The upstream beta pyproject packages only fish_speech itself, so its
# wheel omits fish_speech.inference_engine. Install the wheel for its
# metadata, then restore the complete official source package.
with tempfile.TemporaryDirectory(prefix="tts_fish_s2_") as temp_dir:
source_dir = Path(temp_dir) / "fish-speech"
env = os.environ.copy()
if self.is_windows:
env["PYTHONUTF8"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
self.log("Downloading tested Fish S2 source revision...", "INSTALL")
try:
clone_result = subprocess.run(
[
"git",
"clone",
"--depth",
"1",
"--branch",
fish_revision,
fish_repository,
str(source_dir),
],
capture_output=True,
text=True,
encoding="utf-8" if self.is_windows else None,
errors="replace" if self.is_windows else None,
check=True,
env=env,
)
if clone_result.stdout.strip():
print(clone_result.stdout)
except (OSError, subprocess.CalledProcessError) as error:
details = getattr(error, "stderr", None) or str(error)
self.log(f"Fish S2 source download failed: {details.strip()}", "ERROR")
else:
installed = self.run_pip_command(
["install", "--upgrade", "--force-reinstall", str(source_dir), "--no-deps"],
"Installing tested Fish S2 runtime from GitHub (--no-deps)",
ignore_errors=True,
)
if installed:
self._restore_fish_source_package(source_dir)
if self.module_available(fish_runtime_module):
self.log("Fish S2 inference runtime installed successfully", "SUCCESS")
else:
self.log(
"Fish S2 inference runtime is still unavailable after installation; Fish Audio S2 will not work until the package is repaired",
"ERROR",
)
def _restore_fish_source_package(self, source_dir: Path) -> bool:
"""Restore Fish subpackages omitted by the upstream wheel metadata."""
source_package = source_dir / "fish_speech"
if not source_package.is_dir():
self.log("Fish S2 source checkout does not contain fish_speech", "ERROR")
return False
import site
import sysconfig
package_roots = list(sys.path)
package_roots.extend(site.getsitepackages())
package_roots.append(site.getusersitepackages())
package_roots.extend(
path
for path in (
sysconfig.get_paths().get("purelib"),
sysconfig.get_paths().get("platlib"),
)
if path
)
target_package = None
seen_roots = set()
for entry in package_roots:
if not entry or entry in seen_roots:
continue
seen_roots.add(entry)
candidate = Path(entry) / "fish_speech"
# Fish S2 is an implicit namespace package and intentionally has
# no fish_speech/__init__.py in the official source tree.
if candidate.is_dir():
target_package = candidate
break
if target_package is None:
self.log("Could not locate the active site-packages/fish_speech directory", "ERROR")
return False
try:
shutil.copytree(source_package, target_package, dirs_exist_ok=True)
except OSError as error:
self.log(f"Could not restore the Fish S2 source package: {error}", "ERROR")
return False
importlib.invalidate_caches()
self.log(
f"Restored complete Fish S2 source package in {target_package}",
"SUCCESS",
)
return True
def install_f5tts_multilingual_support(self):
"""Install phonemization support for F5-TTS multilingual models (Polish, German, French, Spanish, etc.)"""
self.log("Installing F5-TTS multilingual phonemization support", "INFO")
if self.is_windows:
# Windows: pip package that includes espeak binaries (no separate system install needed)
self.log("Windows detected - installing espeak-phonemizer-windows for multilingual F5-TTS", "INFO")
if self.check_package_installed("espeak-phonemizer-windows"):
self.log("espeak-phonemizer-windows already satisfied - skipping", "SUCCESS")
else:
self.run_pip_command(
["install", "espeak-phonemizer-windows"],
"Installing espeak-phonemizer-windows (includes binaries)",
ignore_errors=True
)
# Test if it works
try:
import subprocess
result = subprocess.run([
sys.executable, "-c",
"from espeak_phonemizer import Phonemizer; p=Phonemizer(); p.phonemize('test', voice='en')"
], capture_output=True, timeout=10)
if result.returncode == 0:
self.log("espeak-phonemizer-windows working - multilingual F5-TTS models will work properly", "SUCCESS")
else:
self.log("espeak-phonemizer-windows test failed - non-English F5-TTS models may have quality issues", "WARNING")
except Exception:
self.log("Could not test espeak-phonemizer-windows installation", "WARNING")
else:
# Linux/Mac: pip package + separate system dependency
self.log("Linux/Mac detected - installing phonemizer for multilingual F5-TTS support", "INFO")
if self.check_package_installed("phonemizer"):
phonemizer_installed = True
self.log("phonemizer already satisfied - skipping", "SUCCESS")
else:
phonemizer_installed = self.run_pip_command(
["install", "phonemizer"],
"Installing phonemizer package",
ignore_errors=True
)
if phonemizer_installed:
# Test if system espeak is available
try:
import subprocess
result = subprocess.run([
sys.executable, "-c",
"from phonemizer import phonemize; phonemize('test', language='en', backend='espeak')"
], capture_output=True, timeout=10)
if result.returncode == 0:
self.log("phonemizer + system espeak working - multilingual F5-TTS models will work properly", "SUCCESS")
else:
self.log("phonemizer installed but system espeak dependency missing", "WARNING")
self.log("To enable multilingual F5-TTS support, install system espeak:", "INFO")
if self.is_macos:
print(" brew install espeak")
else: # Linux
print(" sudo apt-get install espeak espeak-data # Ubuntu/Debian")
print(" sudo dnf install espeak espeak-devel # Fedora/RHEL")
self.log("Non-English F5-TTS models will fall back to character-based processing", "WARNING")
except Exception as e:
self.log(f"Could not test phonemizer installation: {e}", "WARNING")
else:
self.log("phonemizer installation failed - non-English F5-TTS models will use fallback processing", "WARNING")
def install_indexts_text_processing(self):
"""Install IndexTTS-2 text processing with smart fallback handling"""
self.log("Installing IndexTTS-2 text normalization support", "INFO")
# WeTextProcessing requires manual wheel installation on Windows (build tools needed)
# Skip it on Windows and use fallback instead to avoid confusing error messages
if self.is_windows:
self.log("Windows detected - WeTextProcessing requires manual build, using fallback approach", "INFO")
wetextprocessing_success = False
else:
# Try WeTextProcessing first (newer, preferred package) - only on Linux/Mac
wetextprocessing_success = self.run_pip_command(
["install", "WeTextProcessing"],
"Installing WeTextProcessing (Chinese/English normalization)",
ignore_errors=True
)
if wetextprocessing_success:
# Test if WeTextProcessing actually works
try:
result = subprocess.run([
sys.executable, "-c",
"from WeTextProcessing import Normalizer; n=Normalizer(lang='en', operator='tn'); print('OK')"
], capture_output=True, timeout=10)
if result.returncode == 0:
self.log("WeTextProcessing installed and working - IndexTTS-2 will have full text normalization", "SUCCESS")
return
else:
self.log("WeTextProcessing installed but not working - trying fallback", "WARNING")
except Exception:
self.log("Could not test WeTextProcessing - trying fallback", "WARNING")
# Fallback to wetext (older package, more compatible)
if self.check_package_installed("wetext"):
wetext_success = True
self.log("wetext already satisfied - skipping", "SUCCESS")
else:
wetext_success = self.run_pip_command(
["install", "wetext"],
"Installing wetext (fallback text normalization)",
ignore_errors=True
)
if wetext_success:
try:
result = subprocess.run([
sys.executable, "-c",
"from wetext import Normalizer; n=Normalizer(lang='en', operator='tn'); print('OK')"
], capture_output=True, timeout=10)
if result.returncode == 0:
self.log("wetext fallback working - IndexTTS-2 will have basic text normalization", "SUCCESS")
return
else:
self.log("wetext installed but not working", "WARNING")
except Exception:
self.log("Could not test wetext installation", "WARNING")
# Both failed - IndexTTS-2 will use basic processing
self.log("Text normalization packages failed to install - IndexTTS-2 will use basic text processing", "WARNING")
self.log("This may affect quality for Chinese text and complex English patterns", "INFO")
def install_russian_text_stresser_support(self):
"""Install lightweight Russian stress support for Official 23-Lang."""
self.log("Installing Russian stress labeling support for Official 23-Lang", "INFO")
dependency_specs = [
"poetry-core>=2.0.0",
"spacy<4",
"pymorphy2>=0.9.1",
"stressed-cyrillic-tools>=0.1.10",
]
for spec in dependency_specs:
if self.check_package_installed(spec):
self.log(f"{spec} already satisfied - skipping", "SUCCESS")
continue
self.run_pip_command(["install", spec], f"Installing {spec}", ignore_errors=True)
if self.check_package_installed("russian-text-stresser>=0.1.2"):
self.log("Patched russian-text-stresser already satisfied - skipping", "SUCCESS")
else:
installed = self.run_pip_command(
[
"install",
"--force-reinstall",
"--no-deps",
"--no-build-isolation",
"--ignore-requires-python",
self.russian_stress_fork_ref,
],
"Installing patched russian-text-stresser from fork",
ignore_errors=True,
)
if not installed:
self.log("Patched russian-text-stresser installation failed - Russian stress marks will remain unavailable", "WARNING")
return
env = os.environ.copy()
if self.is_windows:
env["PYTHONUTF8"] = "1"
env["PYTHONIOENCODING"] = "utf-8"
try:
result = subprocess.run(
[
sys.executable,
"-c",
(
"from russian_text_stresser import russian_dictionary as rd; "
"print('OK' if hasattr(rd, 'DATA_DIR_ENV_VAR') else 'MISSING_PATCH')"
),
],
capture_output=True,
text=True,
timeout=30,
env=env,
encoding="utf-8" if self.is_windows else None,
)
if result.returncode == 0 and "OK" in (result.stdout or ""):
self.log("Russian stress labeling package installed", "SUCCESS")
else:
error_text = (result.stderr or result.stdout or "").strip()
self.log(f"Russian stress labeling package validation failed: {error_text}", "WARNING")
except Exception as e:
self.log(f"Could not validate russian-text-stresser installation: {e}", "WARNING")
def check_python_environment(self):
"""Check Python environment and warn about potential mismatches"""
python_path = sys.executable.lower()
# Check for virtual environment mismatch (Windows py launcher issue)
if 'VIRTUAL_ENV' in os.environ:
venv_path = os.environ['VIRTUAL_ENV']
if self.is_windows and not sys.executable.startswith(venv_path):
self.log("WARNING: Python version mismatch detected!", "WARNING")
self.log(f"Virtual environment: {venv_path}", "WARNING")
self.log(f"Current Python: {sys.executable}", "WARNING")
self.log("You likely used 'py install.py' instead of 'python install.py'", "WARNING")
self.log("Use 'python install.py' to match your ComfyUI Python version", "INFO")
print() # Add spacing for visibility
return False
# Check for clearly identifiable system Python paths
system_python_patterns = [
"c:\\python", # Windows system Python
"/usr/bin/python", # Linux system Python
"/usr/local/bin/python", # macOS Homebrew system Python
"system32", # Windows system directory
]
if any(pattern in python_path for pattern in system_python_patterns):
self.log("WARNING: Detected system-wide Python installation", "WARNING")
self.log(f"Current Python: {sys.executable}", "WARNING")
self.log("This may install packages to the wrong location", "WARNING")
self.log("For best results, use ComfyUI Manager for automatic installation", "INFO")
return False
return True
def handle_wandb_issues(self):
"""Fix wandb circular import issues that affect multiple nodes"""
self.log("Checking and fixing wandb import issues", "INFO")
try:
# Try to import wandb to check for issues
import wandb
# Try to access the errors attribute that's causing the circular import
hasattr(wandb, 'errors')
self.log("wandb import test passed", "SUCCESS")
except (ImportError, AttributeError) as e:
self.log(f"wandb import issue detected: {e}", "WARNING")
self.log("Reinstalling wandb to fix circular import", "WARNING")
# Uninstall and reinstall wandb cleanly
self.run_pip_command(
["uninstall", "-y", "wandb"],
"Uninstalling problematic wandb",
ignore_errors=True
)
# Clear any cached/partial installations
self.run_pip_command(
["install", "--no-cache-dir", "--force-reinstall", "wandb>=0.17.0"],
"Reinstalling wandb cleanly"
)
# Test again after reinstallation
try:
import importlib
importlib.invalidate_caches() # Clear import cache
import wandb
hasattr(wandb, 'errors')
self.log("wandb reinstallation successful", "SUCCESS")
except Exception as retry_error:
self.log(f"wandb reinstallation still has issues: {retry_error}", "ERROR")
self.log("Some F5-TTS and Higgs features may not work properly", "WARNING")
def handle_python_313_specific(self):
"""Handle Python 3.13 specific compatibility issues"""
if not self.is_python_313:
self.log("Python < 3.13 detected, skipping 3.13-specific workarounds", "INFO")
return
self.log("Python 3.13 detected - applying compatibility measures", "WARNING")
# MediaPipe is incompatible - inform user about OpenSeeFace alternative
self.log("MediaPipe is incompatible with Python 3.13", "WARNING")
self.log("OpenSeeFace will be used automatically for mouth movement analysis", "INFO")
self.log("Note: OpenSeeFace is experimental and may be less accurate than MediaPipe", "WARNING")
# OpenSeeFace only needs an ONNX Runtime provider. Do not replace a working GPU install.
if self.has_onnxruntime_provider():
if self.check_package_installed("onnxruntime-gpu>=1.19.0"):
self.log("Python 3.13: onnxruntime-gpu already available - skipping CPU reinstall", "SUCCESS")
else:
self.log("Python 3.13: onnxruntime already available - skipping reinstall", "SUCCESS")
return
self.run_pip_command(
["install", "onnxruntime", "--no-deps"],
"Installing onnxruntime for OpenSeeFace (Python 3.13)",
ignore_errors=True
)
def validate_installation(self):
"""Validate package presence without importing heavyweight runtimes."""
self.log("Validating installation...", "INFO")
validation_errors = []
for module_name, display_name in self.CORE_MODULE_CHECKS:
if self.module_available(module_name):
self.log(f"{display_name}: OK", "SUCCESS")
else:
validation_errors.append(f"{display_name}: module is unavailable")
self.log(f"{display_name}: FAILED - module is unavailable", "ERROR")
# Validate actual runtime entry points instead of relying on package
# distribution metadata. This is intentionally presence-only.
for display_name, module_names in self.ENGINE_RUNTIME_CHECKS:
missing_modules = [
module_name
for module_name in module_names
if not self.module_available(module_name)
]
available = not missing_modules
self.engine_validation_results[display_name] = available
if available:
self.log(f"{display_name} runtime: OK", "SUCCESS")
else:
validation_errors.append(
f"{display_name} runtime is unavailable ({', '.join(missing_modules)})"
)
self.log(
f"{display_name} runtime: FAILED ({', '.join(missing_modules)})",
"ERROR",
)
# Dots supplies a no-op normalizer when tn/WeTextProcessing is absent;
# this affects text normalization quality, not engine availability.
if not self.module_available("tn.chinese.normalizer"):
warning = "Dots TTS text normalization unavailable; Dots will use its built-in no-op fallback"
self.add_install_warning(
"Dots TTS text normalization (optional)",
warning,
)
self.log(warning, "WARNING")
# Check Python 3.13 specific validations
if self.is_python_313:
if self.module_available("onnxruntime"):
self.log("ONNXRuntime (OpenSeeFace): OK", "SUCCESS")
else:
validation_errors.append("ONNXRuntime required for OpenSeeFace on Python 3.13")
self.log("ONNXRuntime (OpenSeeFace): FAILED", "ERROR")
# Check RVC dependencies
rvc_modules = [("monotonic_alignment_search", "Monotonic Alignment Search")]
for module_name, display_name in rvc_modules:
if self.module_available(module_name):
self.log(f"{display_name} (RVC): OK", "SUCCESS")
else:
# RVC is optional, so this is just a warning
self.log(f"{display_name} (RVC): Not available - RVC voice conversion will not work", "WARNING")
return len(validation_errors) == 0
def check_version_conflicts(self):
"""Check for known version conflicts"""
self.log("Checking for version conflicts...", "INFO")
try:
from importlib.metadata import version, PackageNotFoundError
numpy_version_text = version("numpy")
numpy_version = tuple(map(int, numpy_version_text.split('.')[:2]))
if numpy_version >= (2, 3):
self.log(f"WARNING: NumPy {numpy_version_text} detected - may cause numba conflicts", "WARNING")
self.log("Consider downgrading: pip install 'numpy>=2.2.0,<2.3.0'", "WARNING")
else:
self.log(f"NumPy {numpy_version_text}: Version OK for compatibility", "SUCCESS")
except (PackageNotFoundError, ImportError, ValueError):
self.log("NumPy not found - this will cause issues", "ERROR")
def print_installation_summary(self, installation_valid: bool):
"""Print installation summary and next steps"""
print("\n" + "="*70)
print(" "*20 + "TTS AUDIO SUITE INSTALLATION")
print("="*70)
if installation_valid:
if self.install_warnings:
self.log(
f"Installation completed with {len(self.install_warnings)} warning(s)",
"WARNING",
)
else:
self.log("Installation completed successfully!", "SUCCESS")
else:
self.log("Installation completed with errors - one or more required runtimes are unavailable", "ERROR")
print(f"\n>>> Python version: {self.python_version.major}.{self.python_version.minor}.{self.python_version.micro}")
if self.is_macos:
print(f">>> Platform: macOS ({platform.machine()})")
if self.is_python_313:
print("\n" + "-"*50)
print(" PYTHON 3.13 COMPATIBILITY STATUS")
print("-"*50)
print(" [+] Python 3.13 compatibility workarounds applied")
print(" [+] OpenSeeFace mouth movement: WORKING (experimental)")
print(" [+] Numba/Librosa compatibility: FIXED")
print(" -> Automatic JIT disabling for Python 3.13")
print(" [X] MediaPipe mouth movement: INCOMPATIBLE")
print(" -> Use OpenSeeFace alternative")
print("\n>> Want MediaPipe Python 3.13 support? Vote at:")
print(" https://github.com/google-ai-edge/mediapipe/issues/5708")
else:
print("\n" + "-"*50)
print(" CORE COMPATIBILITY STATUS")
print("-"*50)
print(" [+] Core compatibility checks completed")
print("\n" + "-"*50)
print(" EXTERNAL ENGINE RUNTIME CHECKS")
print("-"*50)
print(" Bundled and isolated engines use the core/shared runtime checks.")
for display_name, available in self.engine_validation_results.items():
status = "AVAILABLE" if available else "UNAVAILABLE"
marker = "+" if available else "X"
print(f" [{marker}] {display_name}: {status}")
if self.install_warnings:
print("\n" + "-"*50)
print(" INSTALLATION WARNINGS")
print("-"*50)
for description, _ in self.install_warnings:
print(f" [!] {description}")
print("\n" + "="*70)
if installation_valid:
print(" "*15 + "READY TO USE TTS AUDIO SUITE IN COMFYUI!")
else:
print(" "*12 + "INSTALLATION NEEDS ATTENTION BEFORE USE")
print("="*70)
# Windows-specific note about text normalization
if self.is_windows:
print("\n" + "-"*50)
print(" WINDOWS NOTES")
print("-"*50)
print(" WeTextProcessing was skipped (requires manual wheel build).")
print(" IndexTTS-2 will use fallback text processing instead - this")
print(" means slightly lower quality for Chinese text, but all TTS")
print(" generation will work fine.")
print("\n This is normal and expected on Windows!")
print()
def main():
"""Main installation entry point"""
installer = TTSAudioInstaller()
try:
installer.log("Starting TTS Audio Suite installation", "INFO")
installer.log(f"Python {installer.python_version.major}.{installer.python_version.minor}.{installer.python_version.micro} detected", "INFO")
# Check environment and system dependencies before proceeding
installer.check_python_environment()
if installer.can_skip_dependency_installation():
installer.log("No dependency changes detected; continuing with fast validation only", "INFO")
else:
installer.ensure_requirements_installed() # Ensure requirements.txt is installed first
# Report optional system dependencies without blocking engine setup.
installer.check_system_dependencies()
# Install in correct order to prevent conflicts
installer.install_pytorch_with_cuda() # Install PyTorch first with proper CUDA detection
installer.install_core_dependencies()
installer.install_macos_specific_packages() # Mac-specific package fixes
installer.install_numpy_with_constraints()
installer.install_audio_separator_if_compatible() # Install audio-separator only if numpy>=2
installer.install_rvc_dependencies()
installer.install_gradio_and_opencv_dependencies() # Pre-install deps before --no-deps
installer.install_problematic_packages()
installer.install_onnxruntime_with_gpu_support() # Install ONNX with GPU acceleration if available
installer.install_vibevoice() # Install VibeVoice with careful dependency management
installer.install_echo_tts() # Install Echo-TTS with minimal dependency impact
installer.install_dots_tts() # Install official Dots TTS in the main environment first
installer.install_fish_audio_s2() # Install Fish S2 without changing Torch/Transformers
installer.install_f5tts_multilingual_support() # Install phonemization for Polish/multilingual F5-TTS
installer.install_indexts_text_processing() # Install IndexTTS-2 text normalization with fallback
installer.install_russian_text_stresser_support() # Install lightweight Russian stress package for Official 23-Lang
installer.handle_wandb_issues() # Fix wandb circular import
installer.handle_python_313_specific()
# Validation and summary
installer.check_version_conflicts()
success = installer.validate_installation()
installer.save_installation_state(success)
installer.print_installation_summary(success)
if not success:
installer.log("Installation finished with errors - review the failed runtime checks above", "ERROR")
sys.exit(1)
elif installer.install_warnings:
installer.log("Installation finished with warnings - review the installation warnings above", "WARNING")
sys.exit(0)
else:
installer.log("Installation completed successfully!", "SUCCESS")
sys.exit(0)
except Exception as e:
installer.log(f"Installation failed: {e}", "ERROR")
installer.log("Please check the error messages above and try again", "ERROR")
sys.exit(1)
if __name__ == "__main__":
main()