1989 lines
93 KiB
Python
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()
|