Files
diodiogod-TTS-Audio-Suite/engines/vibevoice_engine/vibevoice_engine.py
diodiogod 08b50473f5 Repair Fish and VibeVoice runtime paths for issue 322
- Restore the removed TorchAudio backend probe before Fish initializes
- Resolve local VibeVoice models in the parent ComfyUI process
- Pass absolute model paths into isolated VibeVoice workers
- Add regression coverage for both compatibility paths
2026-07-15 15:13:27 -03:00

1338 lines
62 KiB
Python

"""
VibeVoice Engine - Main TTS engine wrapper for ComfyUI integration
Provides multi-speaker text-to-speech with long-form generation capabilities
Based on Microsoft VibeVoice model
"""
import torch
import numpy as np
import os
import sys
import gc
import logging
from typing import Dict, Any, Optional, List, Tuple
from pathlib import Path
from packaging import version
# Apply accelerate compatibility patches
try:
from utils.compatibility.transformers_patches import TransformersPatches
TransformersPatches.patch_accelerate_compatibility(verbose=True)
except ImportError:
print("⚠️ Could not import transformers patches")
import transformers
# Add parent directory for imports
current_dir = os.path.dirname(__file__)
engines_dir = os.path.dirname(current_dir)
project_root = os.path.dirname(engines_dir)
if project_root not in sys.path:
sys.path.insert(0, project_root)
# Import utilities
from utils.audio.processing import AudioProcessingUtils
from utils.audio.cache import CacheKeyGenerator, get_audio_cache
from utils.text.chunking import ImprovedChatterBoxChunker
from .vibevoice_downloader import (
VibeVoiceDownloader,
VIBEVOICE_MODELS,
is_kugelaudio_variant_name,
)
import folder_paths
# Import unified model interface for ComfyUI integration
from utils.models.unified_model_interface import load_tts_model
# Setup logging
logger = logging.getLogger("VibeVoice")
# The unified loader sometimes imports this module directly, bypassing
# engines.vibevoice_engine.__init__ where these compatibility shims normally run.
try:
from .transformers_compatibility import apply_all_compatibility_patches
apply_all_compatibility_patches()
except Exception as e:
logger.warning(f"Could not apply VibeVoice transformers compatibility patches: {e}")
# Check transformers version for dtype parameter compatibility
_transformers_version = version.parse(transformers.__version__)
_DTYPE_ARG_SUPPORTED = _transformers_version >= version.parse("4.56.0")
# Try importing SageAttention support
try:
from .sage_attention_patch import (
SAGE_ATTENTION_AVAILABLE,
set_sage_attention,
restore_original_attention,
SAGE_ATTENTION_FUNCTION
)
except ImportError:
SAGE_ATTENTION_AVAILABLE = False
set_sage_attention = None
restore_original_attention = None
SAGE_ATTENTION_FUNCTION = None
# Convert token-based limits to character-based for unified chunker
def tokens_to_chars(max_tokens: int) -> int:
"""Convert VibeVoice token limit to character limit for unified chunker"""
# VibeVoice uses ~4 chars per token, but we use conservative 3.5 for safety
return int(max_tokens * 3.5)
class VibeVoiceEngine:
"""
Main VibeVoice engine wrapper for ComfyUI
Handles model loading, text generation, and multi-speaker support
"""
def __init__(self):
"""Initialize VibeVoice engine"""
self.model = None
self.processor = None
self.current_model_name = None
self.model_path = None
self.device = None
self.attention_mode = None # Track current attention mode for re-patching
# Use global shared cache
self.cache = get_audio_cache()
self.downloader = VibeVoiceDownloader()
# Chunking support
self.chunker = ImprovedChatterBoxChunker()
# Track if package is available
self._package_available = None
def _align_quantized_aux_buffers(self) -> None:
"""
Move small registered inner-model buffers to the main model device after
bitsandbytes/device_map loading.
VibeVoice exposes `speech_bias_factor`/`speech_scaling_factor` through
read-only wrapper properties on the inference class, so assigning to
`self.model.speech_bias_factor` fails even though the real buffers are
writable on `self.model.model`.
"""
if self.model is None:
return
inner_model = getattr(self.model, "model", None)
if inner_model is None:
return
try:
main_device = next(self.model.parameters()).device
except StopIteration:
return
moved_buffers = []
for buffer_name in ("speech_bias_factor", "speech_scaling_factor"):
if buffer_name not in getattr(inner_model, "_buffers", {}):
continue
buffer = inner_model._buffers.get(buffer_name)
if not isinstance(buffer, torch.Tensor) or buffer.device == main_device:
continue
inner_model._buffers[buffer_name] = buffer.to(main_device)
moved_buffers.append(buffer_name)
if moved_buffers:
print(f" 🔧 Aligned VibeVoice buffers to {main_device}: {', '.join(moved_buffers)}")
def to(self, device):
"""
Move all model components to the specified device.
Critical for ComfyUI model management - ensures all components move together
when models are detached to CPU and later reloaded to CUDA.
"""
self.device = device
# Move the model
if hasattr(self.model, 'to'):
self.model = self.model.to(device)
# Move the processor if it has tensors
if hasattr(self.processor, 'to'):
self.processor = self.processor.to(device)
return self
def _ensure_package(self) -> bool:
"""Ensure VibeVoice package is installed"""
if self._package_available is not None:
return self._package_available
self._package_available = self.downloader.ensure_vibevoice_package()
return self._package_available
def get_available_models(self) -> List[str]:
"""Get list of available VibeVoice models"""
return self.downloader.get_available_models()
def initialize_engine(self,
model_name: str = "vibevoice-1.5B",
device: str = "auto",
attention_mode: str = "auto",
quantize_llm_4bit: bool = False,
model_path: Optional[str] = None) -> None:
"""
Initialize VibeVoice engine with specified model.
Args:
model_name: Model to load ("vibevoice-1.5B" or "vibevoice-7B")
device: Device to use ("auto", "cuda", or "cpu")
attention_mode: Attention implementation ("auto", "eager", "sdpa", "flash_attention_2")
quantize_llm_4bit: Enable 4-bit LLM quantization for VRAM savings
"""
# Model will be loaded fresh - unified manager handles caching
current_config = (model_name, device, attention_mode, quantize_llm_4bit)
# Ensure package is installed
if not self._ensure_package():
raise RuntimeError("VibeVoice package not available. Please install it manually.")
# Import VibeVoice modules
try:
# Import VibeVoice
import vibevoice
from vibevoice.modular.modeling_vibevoice_inference import VibeVoiceForConditionalGenerationInference
from vibevoice.processor.vibevoice_processor import VibeVoiceProcessor
except ImportError as e:
raise RuntimeError(f"VibeVoice package not installed. Please install with: pip install git+https://github.com/microsoft/VibeVoice.git\nError: {e}")
# Get model path (downloads if necessary)
model_path = model_path or self.downloader.get_model_path(model_name)
if not model_path:
raise RuntimeError(f"Failed to get VibeVoice model '{model_name}'")
# Check if this is KugelAudio
self.is_kugelaudio = self._is_kugelaudio_model(model_name, model_path)
if self.is_kugelaudio:
return self._initialize_kugelaudio(model_path, model_name, device, attention_mode, quantize_llm_4bit)
# Determine device
from utils.device import resolve_torch_device
device = resolve_torch_device(device)
print(f"🔄 Loading VibeVoice model '{model_name}' on {device}...")
if attention_mode != "auto":
print(f" 🧠 Using {attention_mode} attention")
if quantize_llm_4bit:
print(f" 🗜️ 4-bit LLM quantization enabled")
try:
# Import required modules
from transformers import BitsAndBytesConfig
# Determine base dtype
if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
model_dtype = torch.bfloat16
else:
model_dtype = torch.float16
# Build quantization config if requested
quant_config = None
final_load_dtype = model_dtype
if quantize_llm_4bit:
# Default compute dtype for quantization
bnb_compute_dtype = model_dtype
# SageAttention requires fp32 compute dtype for stability with 4-bit
if attention_mode == 'sage':
print(f" 🎯 Using SageAttention with 4-bit: forcing fp32 compute dtype for stability")
bnb_compute_dtype = torch.float32
final_load_dtype = torch.float32
else:
print(f" 📊 Using {attention_mode} with 4-bit: using {model_dtype} compute dtype")
quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=bnb_compute_dtype,
)
print(f" 🗜️ 4-bit quantization enabled")
# Determine final attention mode
final_attention_mode = attention_mode
if attention_mode == "auto":
# Auto-select best available attention
# Check SageAttention first
if SAGE_ATTENTION_AVAILABLE and SAGE_ATTENTION_FUNCTION is not None:
final_attention_mode = "sage"
print(f" 🚀 Auto-selected SageAttention (GPU-optimized mixed-precision)")
else:
# Try flash attention
try:
import flash_attn
final_attention_mode = "flash_attention_2"
print(f" ✨ Auto-selected flash_attention_2")
except ImportError:
final_attention_mode = "sdpa" # PyTorch SDPA as fallback
print(f" ⚡ Auto-selected sdpa (flash_attention_2 not available)")
# For SageAttention, we need to load with SDPA and patch later
attn_implementation_for_load = "sdpa" if final_attention_mode == "sage" else final_attention_mode
# Build model kwargs with version-compatible dtype parameter
model_kwargs = {
"trust_remote_code": True,
}
# Use correct dtype parameter based on transformers version
if _DTYPE_ARG_SUPPORTED:
model_kwargs['dtype'] = final_load_dtype
else:
model_kwargs['torch_dtype'] = final_load_dtype
# Set device_map based on quantization and device (original logic)
if quant_config:
# For quantization, use explicit device mapping to avoid buffer issues
if device == "cuda" or device == "auto":
model_kwargs["device_map"] = {"": 0} # Put everything on GPU 0
else:
model_kwargs["device_map"] = {"": "cpu"}
# print(f"🔧 VibeVoice: Using device_map for quantization: {model_kwargs['device_map']}")
else:
model_kwargs["device_map"] = device if device != "auto" else None
# print(f"🔧 VibeVoice: Using device_map: {model_kwargs.get('device_map', 'None')}")
# Add attention implementation (use SDPA for SageAttention, patch later)
if attn_implementation_for_load != "auto":
model_kwargs["attn_implementation"] = attn_implementation_for_load
# Add quantization config if enabled
if quant_config:
model_kwargs["quantization_config"] = quant_config
# Check if this is a standalone .safetensors file
is_standalone = model_path.endswith('.safetensors')
# Load model with enhanced configuration
try:
if is_standalone:
print(f"🔧 Loading standalone VibeVoice model from: {model_path}")
# Load state_dict directly from .safetensors file
state_dict = self._load_standalone_state_dict(model_path, device)
# Load config from sidecar file or use fallback
config_path = self._get_standalone_config_path(model_path, model_name)
# Load with state_dict
model_kwargs["state_dict"] = state_dict
self.model = VibeVoiceForConditionalGenerationInference.from_pretrained(
config_path if config_path else None,
**model_kwargs
)
else:
# Regular directory-based loading
self.model = VibeVoiceForConditionalGenerationInference.from_pretrained(
model_path,
**model_kwargs
)
# Apply SageAttention if selected, restore original if not
if final_attention_mode == "sage":
if SAGE_ATTENTION_AVAILABLE and set_sage_attention:
print(f" 🎯 Applying SageAttention patch to model...")
try:
set_sage_attention(self.model)
print(f" ✅ SageAttention successfully applied")
except Exception as sage_error:
print(f" ⚠️ Failed to apply SageAttention: {sage_error}")
print(f" 📌 Falling back to SDPA")
final_attention_mode = "sdpa"
else:
print(f" ⚠️ SageAttention not available, using SDPA")
final_attention_mode = "sdpa"
else:
# Restore original attention if switching away from SageAttention
if restore_original_attention:
print(f" 🔄 Restoring original attention (cleaning SageAttention patches)")
restore_original_attention(self.model)
# Store attention mode for re-patching after device movement
self.attention_mode = final_attention_mode
# Set model to evaluation mode and mark quantization status
self.model.eval()
if quant_config:
setattr(self.model, "_llm_4bit", True)
except Exception as e:
# Fallback logic for attention modes
if final_attention_mode in ["sage", "flash_attention_2"]:
print(f"⚠️ Failed with {final_attention_mode}, trying SDPA fallback: {e}")
# Retry with SDPA
model_kwargs["attn_implementation"] = "sdpa"
try:
self.model = VibeVoiceForConditionalGenerationInference.from_pretrained(
model_path,
**model_kwargs
)
self.model.eval()
if quant_config:
setattr(self.model, "_llm_4bit", True)
final_attention_mode = "sdpa"
except Exception as sdpa_error:
print(f"⚠️ SDPA also failed, trying eager: {sdpa_error}")
model_kwargs["attn_implementation"] = "eager"
self.model = VibeVoiceForConditionalGenerationInference.from_pretrained(
model_path,
**model_kwargs
)
self.model.eval()
if quant_config:
setattr(self.model, "_llm_4bit", True)
final_attention_mode = "eager"
elif quant_config:
# If quantization fails, try without it
print(f"⚠️ Quantization failed, falling back to full precision: {e}")
model_kwargs_fallback = model_kwargs.copy()
model_kwargs_fallback.pop("quantization_config", None)
model_kwargs_fallback["device_map"] = device if device != "auto" else None
self.model = VibeVoiceForConditionalGenerationInference.from_pretrained(
model_path,
**model_kwargs_fallback
)
self.model.eval()
quant_config = None # Mark as no quantization for later logic
else:
raise
# Load processor with unified tokenizer handling
# For standalone models, we need to pass the directory for processor configs
processor_path = os.path.dirname(model_path) if is_standalone else model_path
self.processor = self._load_processor_with_unified_tokenizer(processor_path, model_name)
# 🔧 Only move if NOT already placed by device_map
if not quant_config and device != "cpu" and not hasattr(self.model, 'hf_device_map'):
self.model = self.model.to(device)
# Align inner-model buffers after quantized/device_map loading.
if quant_config:
try:
self._align_quantized_aux_buffers()
except Exception as device_fix_error:
print(f"⚠️ Device placement fix attempt failed (non-critical): {device_fix_error}")
# Store configuration and model info
self.model_path = model_path
self.device = device
self._original_device = device # Store original device setting for auto detection
self.current_model_name = model_name
self._current_config = current_config
self._quantize_llm_4bit = quantize_llm_4bit
print(f"✅ VibeVoice model '{model_name}' loaded successfully")
print(f" Device: {device}, Attention: {final_attention_mode}")
if quantize_llm_4bit and quant_config:
print(f" 🗜️ LLM quantized to 4-bit (VRAM savings expected)")
elif quantize_llm_4bit and not quant_config:
print(f" ⚠️ Quantization failed, using full precision")
except Exception as e:
logger.error(f"Failed to load VibeVoice model: {e}")
raise RuntimeError(f"Model loading failed: {e}")
def _load_processor_with_unified_tokenizer(self, model_path: str, model_name: str):
"""
Load VibeVoice processor following our ChatterBox-style unified pattern:
1. Check local unified folder first
2. Check HuggingFace cache
3. If missing, download TO unified folder
4. Load from wherever found (no copying)
"""
from vibevoice.processor.vibevoice_processor import VibeVoiceProcessor
from vibevoice.modular.modular_vibevoice_text_tokenizer import VibeVoiceTextTokenizerFast
from huggingface_hub import hf_hub_download
# Determine which Qwen repo to use based on model name
if "7b" in model_name.lower() or "large" in model_name.lower():
qwen_repo = "Qwen/Qwen2.5-7B"
else:
qwen_repo = "Qwen/Qwen2.5-1.5B"
# print(f"🔍 Looking for tokenizer from {qwen_repo} using unified pattern...") # Verbose logging
# Check our unified TTS folder first (use same directory structure as model storage)
tokenizer_unified_path = os.path.join(self.downloader.downloader.tts_dir, "vibevoice", qwen_repo.replace("/", "_"), "tokenizer")
tokenizer_file = os.path.join(tokenizer_unified_path, "tokenizer.json")
tokenizer_source = None
if os.path.exists(tokenizer_file):
tokenizer_source = tokenizer_unified_path
else:
# Check HuggingFace cache
try:
cached_file = hf_hub_download(repo_id=qwen_repo, filename="tokenizer.json", local_files_only=True)
print(f"📁 Using cached tokenizer: {cached_file}")
tokenizer_source = os.path.dirname(cached_file)
except Exception as cache_error:
print(f"📋 Cache check failed: {str(cache_error)[:100]}... - will download")
# Download to unified folder
print(f"📥 Downloading tokenizer to unified folder...")
os.makedirs(tokenizer_unified_path, exist_ok=True)
tokenizer_files = [
{"remote": "tokenizer.json", "local": "tokenizer.json"},
{"remote": "tokenizer_config.json", "local": "tokenizer_config.json"},
]
# Download directly to the unified path instead of using downloader subfolder
try:
from huggingface_hub import hf_hub_download
for file_info in tokenizer_files:
hf_hub_download(
repo_id=qwen_repo,
filename=file_info["remote"],
local_dir=tokenizer_unified_path,
local_dir_use_symlinks=False
)
if os.path.exists(os.path.join(tokenizer_unified_path, "tokenizer.json")):
print(f"✅ Downloaded tokenizer to: {tokenizer_unified_path}")
tokenizer_source = tokenizer_unified_path
else:
raise RuntimeError(f"Failed to download tokenizer files")
except Exception as download_error:
raise RuntimeError(f"Failed to download tokenizer from {qwen_repo}: {download_error}")
# Check if we're using our unified TTS folder (not HF cache)
is_unified_tokenizer = tokenizer_source and tokenizer_unified_path in tokenizer_source
if is_unified_tokenizer:
try:
# We have tokenizer in unified folder - build processor manually to use it
# print(f"🔧 Building processor with unified tokenizer from: {tokenizer_source}") # Verbose logging
from vibevoice.modular.modular_vibevoice_text_tokenizer import VibeVoiceTextTokenizerFast
from vibevoice.processor.vibevoice_tokenizer_processor import VibeVoiceTokenizerProcessor
import json
# Load tokenizer directly with tokenizer_file to avoid class mismatch
tokenizer_file_path = os.path.join(tokenizer_source, "tokenizer.json")
if os.path.exists(tokenizer_file_path):
tokenizer = VibeVoiceTextTokenizerFast(tokenizer_file=tokenizer_file_path)
# print(f"🔧 Loaded tokenizer directly from file: {tokenizer_file_path}") # Verbose logging
else:
# Fallback to from_pretrained (may show warning)
tokenizer = VibeVoiceTextTokenizerFast.from_pretrained(tokenizer_source)
# Load processor config if available
processor_config_path = os.path.join(model_path, "preprocessor_config.json")
processor_config_data = {}
if os.path.exists(processor_config_path):
with open(processor_config_path, 'r', encoding='utf-8') as f:
processor_config_data = json.load(f)
# Create audio processor
audio_processor = VibeVoiceTokenizerProcessor()
# Build final processor
processor = VibeVoiceProcessor(
tokenizer=tokenizer,
audio_processor=audio_processor,
speech_tok_compress_ratio=processor_config_data.get("speech_tok_compress_ratio", 3200),
db_normalize=processor_config_data.get("db_normalize", True)
)
# print(f"✅ Built VibeVoice processor with unified tokenizer") # Verbose logging
return processor
except Exception as e:
print(f"⚠️ Unified tokenizer processor build failed: {e}")
print(f"🔄 Falling back to standard processor loading...")
# For HF cached tokenizer, build processor manually to avoid class mismatch warnings
else:
try:
# print(f"🔧 Building processor with HF cached tokenizer from: {tokenizer_source}") # Verbose logging
from vibevoice.modular.modular_vibevoice_text_tokenizer import VibeVoiceTextTokenizerFast
from vibevoice.processor.vibevoice_tokenizer_processor import VibeVoiceTokenizerProcessor
import json
# Load tokenizer directly with tokenizer_file to avoid class mismatch
tokenizer_file_path = os.path.join(tokenizer_source, "tokenizer.json")
if os.path.exists(tokenizer_file_path):
tokenizer = VibeVoiceTextTokenizerFast(tokenizer_file=tokenizer_file_path)
# print(f"🔧 Loaded tokenizer directly from file: {tokenizer_file_path}") # Verbose logging
else:
# Fallback to from_pretrained (may show warning)
tokenizer = VibeVoiceTextTokenizerFast.from_pretrained(tokenizer_source)
# Load processor config if available
processor_config_path = os.path.join(model_path, "preprocessor_config.json")
processor_config_data = {}
if os.path.exists(processor_config_path):
with open(processor_config_path, 'r', encoding='utf-8') as f:
processor_config_data = json.load(f)
# Create audio processor
audio_processor = VibeVoiceTokenizerProcessor()
# Build final processor
processor = VibeVoiceProcessor(
tokenizer=tokenizer,
audio_processor=audio_processor,
speech_tok_compress_ratio=processor_config_data.get("speech_tok_compress_ratio", 3200),
db_normalize=processor_config_data.get("db_normalize", True)
)
# print(f"✅ Built VibeVoice processor with HF cached tokenizer (no warnings)") # Verbose logging
return processor
except Exception as e:
print(f"⚠️ HF cached tokenizer processor build failed: {e}")
print(f"🔄 Falling back to standard processor loading...")
# Final fallback to standard loading (may show warnings)
try:
processor = VibeVoiceProcessor.from_pretrained(model_path)
print(f"✅ Loaded VibeVoice processor with standard method")
return processor
except Exception as e:
# Ultimate fallback with explicit repo specification
print(f"⚠️ Standard processor loading failed: {e}")
processor = VibeVoiceProcessor.from_pretrained(
model_path,
language_model_pretrained_name=qwen_repo
)
print(f"✅ Loaded VibeVoice processor with explicit tokenizer repo: {qwen_repo}")
return processor
def _load_standalone_state_dict(self, safetensors_path: str, device: str):
"""Load state_dict from standalone .safetensors file using ComfyUI's loader"""
try:
# Use ComfyUI's safetensors loader with device mapping
import comfy.utils
state_dict = comfy.utils.load_torch_file(safetensors_path, device=device)
print(f"✅ Loaded state_dict from: {safetensors_path}")
return state_dict
except Exception as e:
print(f"❌ Failed to load state_dict from {safetensors_path}: {e}")
raise
def _get_standalone_config_path(self, safetensors_path: str, model_name: str) -> Optional[str]:
"""Get config path for standalone model, with fallbacks"""
# Try sidecar config file first
base_path = os.path.splitext(safetensors_path)[0]
sidecar_config = base_path + ".config.json"
if os.path.exists(sidecar_config):
print(f"📄 Using sidecar config: {sidecar_config}")
return sidecar_config
# Try looking for config.json in same directory
dir_config = os.path.join(os.path.dirname(safetensors_path), "config.json")
if os.path.exists(dir_config):
print(f"📄 Using directory config: {dir_config}")
return dir_config
# Fallback to default configs based on model name
config_name = "default_VibeVoice-Large_config.json" if "large" in model_name.lower() or "7b" in model_name.lower() else "default_VibeVoice-1.5B_config.json"
fallback_path = os.path.join(os.path.dirname(__file__), "..", "..", "IgnoredForGitHubDocs", "For_reference", "VibeVoice-ComfyUI-wildminder", "vibevoice", "configs", config_name)
if os.path.exists(fallback_path):
print(f"📄 Using fallback config: {fallback_path}")
return fallback_path
print(f"⚠️ No config found for standalone model, using None (transformers will use defaults)")
return None
def _create_synthetic_voice_sample(self, speaker_idx: int) -> np.ndarray:
"""
Create synthetic voice sample for a specific speaker.
Based on reference implementation but with our own characteristics.
Args:
speaker_idx: Speaker index (0-3)
Returns:
Numpy array with synthetic voice sample
"""
sample_rate = 24000
duration = 1.0
samples = int(sample_rate * duration)
t = np.linspace(0, duration, samples, False)
# Create realistic voice-like characteristics for each speaker
# Use different base frequencies for different speaker types
base_frequencies = [120, 180, 140, 200] # Mix of male/female-like frequencies
base_freq = base_frequencies[speaker_idx % len(base_frequencies)]
# Create vowel-like formants (like "ah" sound) - unique per speaker
formant1 = 800 + speaker_idx * 100 # First formant
formant2 = 1200 + speaker_idx * 150 # Second formant
# Generate more voice-like waveform
voice_sample = (
# Fundamental with harmonics (voice-like)
0.6 * np.sin(2 * np.pi * base_freq * t) +
0.25 * np.sin(2 * np.pi * base_freq * 2 * t) +
0.15 * np.sin(2 * np.pi * base_freq * 3 * t) +
# Formant resonances (vowel-like characteristics)
0.1 * np.sin(2 * np.pi * formant1 * t) * np.exp(-t * 2) +
0.05 * np.sin(2 * np.pi * formant2 * t) * np.exp(-t * 3) +
# Natural breath noise (reduced)
0.02 * np.random.normal(0, 1, len(t))
)
# Add natural envelope (like human speech pattern)
vibrato_freq = 4 + speaker_idx * 0.3 # Slightly different vibrato per speaker
envelope = (np.exp(-t * 0.3) * (1 + 0.1 * np.sin(2 * np.pi * vibrato_freq * t)))
voice_sample *= envelope * 0.08 # Lower volume
return voice_sample.astype(np.float32)
def _prepare_voice_samples(self, voice_refs: List[Optional[Dict]]) -> List[Optional[np.ndarray]]:
"""
Prepare voice samples from ComfyUI audio inputs or None for zero-shot generation.
Args:
voice_refs: List of voice reference dicts from ComfyUI (can contain None)
Returns:
List of numpy arrays or None values (None enables true zero-shot generation)
"""
voice_samples = []
for i, voice_ref in enumerate(voice_refs):
if voice_ref is not None and isinstance(voice_ref, dict):
audio_np = None
input_sample_rate = 24000
if "waveform" in voice_ref:
# Extract waveform from ComfyUI audio format
waveform = voice_ref["waveform"]
input_sample_rate = voice_ref.get("sample_rate", 24000)
shape = tuple(waveform.shape) if hasattr(waveform, "shape") else "unknown"
print(f"🎵 VibeVoice ENGINE: Using waveform input for speaker {i+1} - shape: {shape}, sr: {input_sample_rate}")
# Convert to numpy
if isinstance(waveform, torch.Tensor):
audio_np = waveform.cpu().numpy()
else:
audio_np = np.array(waveform)
elif "audio_path" in voice_ref and voice_ref["audio_path"]:
# Load audio file from path (like TTS Text does)
audio_path = voice_ref["audio_path"]
try:
from utils.audio.librosa_fallback import safe_load
audio_np, input_sample_rate = safe_load(audio_path, sr=None, mono=True)
print(f"🎵 VibeVoice ENGINE: Loaded audio from {audio_path} - shape: {audio_np.shape}, sr: {input_sample_rate}")
except Exception as e:
print(f"⚠️ VibeVoice ENGINE: Failed to load audio from {audio_path}: {e}")
audio_np = None
if audio_np is not None:
# Handle different audio shapes and convert to mono (matches official VibeVoice)
if audio_np.ndim == 3: # (batch, channels, samples)
audio_np = audio_np[0] # Take first batch -> (channels, samples)
if audio_np.ndim == 2:
if audio_np.shape[0] == 2: # (2, time) - stereo
audio_np = np.mean(audio_np, axis=0) # Average both channels
elif audio_np.shape[1] == 2: # (time, 2) - stereo
audio_np = np.mean(audio_np, axis=1) # Average both channels
else:
# If one dimension is 1, squeeze it
if audio_np.shape[0] == 1:
audio_np = audio_np.squeeze(0)
elif audio_np.shape[1] == 1:
audio_np = audio_np.squeeze(1)
else:
# Default: take first channel if not clear stereo format
audio_np = audio_np[0, :]
# Check for invalid values and clean them up
if np.any(np.isnan(audio_np)) or np.any(np.isinf(audio_np)):
print(f"⚠️ VibeVoice ENGINE: Audio contains NaN or Inf values, replacing with zeros")
audio_np = np.nan_to_num(audio_np, nan=0.0, posinf=0.0, neginf=0.0)
# Ensure audio is not completely silent or has extreme values
if np.all(audio_np == 0):
print(f"⚠️ VibeVoice ENGINE: Audio waveform is completely silent")
# Normalize extreme values (prevents generation issues)
max_val = np.abs(audio_np).max()
if max_val > 10.0:
print(f"⚠️ VibeVoice ENGINE: Audio values are very large (max: {max_val}), normalizing")
audio_np = audio_np / max_val
# Resample if needed
if input_sample_rate != 24000:
from utils.audio.librosa_fallback import safe_resample
audio_np = safe_resample(audio_np, orig_sr=input_sample_rate, target_sr=24000)
# Final check after resampling (can introduce artifacts)
if np.any(np.isnan(audio_np)) or np.any(np.isinf(audio_np)):
print(f"⚠️ VibeVoice ENGINE: Audio contains NaN or Inf after resampling, replacing with zeros")
audio_np = np.nan_to_num(audio_np, nan=0.0, posinf=0.0, neginf=0.0)
# Normalize using dB FS (matches official VibeVoice)
target_dB_FS = -25
eps = 1e-6
# First: normalize to target dB FS using RMS
rms = np.sqrt(np.mean(audio_np**2))
scalar = 10 ** (target_dB_FS / 20) / (rms + eps)
audio_np = audio_np * scalar
# Then: avoid clipping
max_val = np.abs(audio_np).max()
if max_val > 1.0:
audio_np = audio_np / (max_val + eps)
voice_samples.append(audio_np.astype(np.float32))
else:
# Use None for true zero-shot generation (processor will skip voice prompt)
voice_samples.append(None)
else:
# Use None for true zero-shot generation (processor will skip voice prompt)
voice_samples.append(None)
return voice_samples
def generate_speech(self,
text: str,
voice_samples: List[np.ndarray],
cfg_scale: float = 1.3,
seed: int = 42,
use_sampling: bool = False,
temperature: float = 0.95,
top_p: float = 0.95,
inference_steps: int = 20,
max_new_tokens: Optional[int] = None,
enable_cache: bool = True,
character: str = "narrator",
stable_audio_component: str = "",
multi_speaker_mode: str = "Custom Character Switching") -> Dict[str, Any]:
"""
Generate speech from text using VibeVoice.
Args:
text: Text to convert (should be formatted with Speaker labels)
voice_samples: List of voice samples for speakers
cfg_scale: Classifier-free guidance scale
seed: Random seed for generation
use_sampling: Whether to use sampling mode
temperature: Temperature for sampling
top_p: Top-p for sampling
inference_steps: Number of diffusion inference steps (5-100)
max_new_tokens: Maximum tokens to generate
Returns:
Dict with "waveform" and "sample_rate"
"""
if self.model is None or self.processor is None:
raise RuntimeError("Model not initialized. Call initialize_engine first.")
# CRITICAL FIX: Reload model to correct device if it was offloaded
# When models are cached, the model reference persists but may be on wrong device after "Clear VRAM"
# IMPORTANT: Always check against the INTENDED device (cuda if available), not self.device which may have been updated to CPU
from utils.device import resolve_torch_device
target_device = resolve_torch_device("auto")
# Check current device by examining actual parameters (not model.device attribute which can be stale)
current_device = None
if hasattr(self.model, 'model') and hasattr(self.model.model, 'parameters'):
try:
first_param = next(self.model.model.parameters())
current_device = str(first_param.device)
except StopIteration:
pass
elif hasattr(self.model, 'parameters'):
try:
first_param = next(self.model.parameters())
current_device = str(first_param.device)
except StopIteration:
pass
# Reload model if device mismatch
# Note: If model is wrapped by unified interface, device movement is handled automatically
# This is only a fallback for unwrapped models or edge cases
if current_device and current_device != str(target_device):
# Normalize device strings for comparison (cuda:0 == cuda)
current_normalized = 'cuda' if current_device.startswith('cuda') else current_device
target_normalized = 'cuda' if str(target_device).startswith('cuda') else str(target_device)
if current_normalized != target_normalized:
print(f"⚠️ Device mismatch detected: model on {current_device}, expected {target_device}")
print(f"⚠️ This shouldn't happen with unified interface - model should already be on correct device")
# Direct movement as last resort (don't call ensure_device during generation - causes OOM)
self.to(target_device)
# Handle caching if enabled (following ChatterBox pattern)
if enable_cache:
from utils.audio.cache import create_cache_function
# print(f"🐛 VibeVoice ENGINE: Creating cache with audio_component='{stable_audio_component[:50]}...'")
# Fix floating point precision issues by rounding to 3 decimal places
cfg_scale_rounded = round(float(cfg_scale), 3) if isinstance(cfg_scale, (int, float)) else cfg_scale
temperature_rounded = round(float(temperature), 3) if isinstance(temperature, (int, float)) else temperature
top_p_rounded = round(float(top_p), 3) if isinstance(top_p, (int, float)) else top_p
cache_fn = create_cache_function(
"vibevoice",
character=character,
cfg_scale=cfg_scale_rounded,
temperature=temperature_rounded,
top_p=top_p_rounded,
use_sampling=use_sampling,
seed=seed,
model_source=self.current_model_name or "vibevoice-1.5B",
device=self.device,
max_new_tokens=max_new_tokens,
audio_component=stable_audio_component,
multi_speaker_mode=multi_speaker_mode,
# New parameters that should invalidate cache
attention_mode=getattr(self, 'attention_mode', 'auto'),
inference_steps=inference_steps,
quantize_llm_4bit=getattr(self, '_quantize_llm_4bit', False)
)
# Try cache first
cached_audio = cache_fn(text)
if cached_audio is not None:
print(f"💾 CACHE HIT for {character}: '{text[:30]}...'")
# print(f"🐛 VibeVoice ENGINE: CACHE HIT - audio_component was '{stable_audio_component[:50]}...'")
# Ensure cached audio is also in Float32 for compatibility
if hasattr(cached_audio, 'dtype') and cached_audio.dtype == torch.bfloat16:
cached_audio = cached_audio.to(torch.float32)
return {
"waveform": cached_audio,
"sample_rate": 24000
}
# Ensure model is on correct device for generation (regardless of cache hit/miss)
# This fixes the issue where "auto" device detection doesn't move model back to GPU after cache hits
if (hasattr(self, '_original_device') and self._original_device == "auto" and
torch.cuda.is_available() and self.model is not None):
actual_device = next(self.model.parameters()).device
if actual_device.type != 'cuda':
print(f"🔄 VibeVoice: Auto mode - moving model from {actual_device} to CUDA for generation")
self.model.to('cuda')
try:
# Set seeds for reproducibility
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
np.random.seed(seed)
# print(f"🐛 VibeVoice ENGINE: Starting generation with {len(voice_samples)} voice samples")
# print(f"🐛 VibeVoice ENGINE: Generation params - cfg_scale={cfg_scale}, use_sampling={use_sampling}, seed={seed}")
# print(f"🐛 VibeVoice ENGINE: Text length: {len(text)} chars")
# KugelAudio specific generation logic
if getattr(self, 'is_kugelaudio', False):
return self._generate_kugelaudio(
text,
voice_samples,
cfg_scale,
seed,
use_sampling,
temperature,
top_p,
inference_steps,
max_new_tokens,
)
# Prepare inputs using processor
inputs = self.processor(
[text], # Wrap text in list
voice_samples=[voice_samples], # Processor expects batch format: [[samples]]
return_tensors="pt",
return_attention_mask=True
)
# print(f"🐛 VibeVoice ENGINE: Processor inputs created - input_ids shape: {inputs['input_ids'].shape}")
# Validate inputs before moving to GPU (prevents corrupted generation)
for key, value in inputs.items():
if isinstance(value, torch.Tensor):
if torch.any(torch.isnan(value)) or torch.any(torch.isinf(value)):
logger.error(f"Input tensor '{key}' contains NaN or Inf values")
raise ValueError(f"Invalid values in input tensor: {key}")
# Move inputs to device (model device already handled above)
actual_device = next(self.model.parameters()).device
inputs = {k: v.to(actual_device) if isinstance(v, torch.Tensor) else v
for k, v in inputs.items()}
# Debug inputs
# Debug: Print input information (commented out for production)
# print(f"🐛 Inputs keys: {list(inputs.keys())}")
# for k, v in inputs.items():
# if isinstance(v, torch.Tensor):
# print(f"🐛 Input {k}: shape={v.shape}, dtype={v.dtype}")
# else:
# print(f"🐛 Input {k}: type={type(v)}, value={v}")
# Set diffusion inference steps (based on wildminder implementation)
# Credits: drbaph's implementation for inference steps control
self.model.set_ddpm_inference_steps(num_steps=inference_steps)
# Ensure model has proper generation config with bos_token_id (silent configuration)
if hasattr(self.model, 'generation_config'):
if not hasattr(self.model.generation_config, 'bos_token_id') or self.model.generation_config.bos_token_id is None:
self.model.generation_config.bos_token_id = 151643 # Qwen2 BOS token
elif hasattr(self.model, 'config'):
if not hasattr(self.model.config, 'bos_token_id') or self.model.config.bos_token_id is None:
self.model.config.bos_token_id = 151643 # Qwen2 BOS token
# Generate with appropriate mode
with torch.no_grad():
# Debug tokenizer bos_token_id (commented out for production)
# if hasattr(self.processor.tokenizer, 'bos_token_id'):
# print(f"🐛 Tokenizer bos_token_id: {self.processor.tokenizer.bos_token_id}")
# else:
# print(f"🐛 Tokenizer has no bos_token_id attribute")
# Ensure proper token IDs are set (critical for stopping generation)
generation_kwargs = {
"tokenizer": self.processor.tokenizer,
"cfg_scale": cfg_scale,
"max_new_tokens": max_new_tokens,
"bos_token_id": 151643, # Qwen2 BOS token
"eos_token_id": self.processor.tokenizer.eos_token_id, # Critical: EOS for stopping
"pad_token_id": self.processor.tokenizer.eos_token_id, # Use EOS as pad
}
if use_sampling:
# Sampling mode
generation_kwargs.update({
"do_sample": True,
"temperature": temperature,
"top_p": top_p,
})
output = self.model.generate(**inputs, **generation_kwargs)
else:
# Deterministic mode
generation_kwargs["do_sample"] = False
output = self.model.generate(**inputs, **generation_kwargs)
# Extract audio from output
if hasattr(output, 'speech_outputs') and output.speech_outputs:
speech_tensors = output.speech_outputs
if isinstance(speech_tensors, list) and len(speech_tensors) > 0:
audio_tensor = torch.cat(speech_tensors, dim=-1)
else:
audio_tensor = speech_tensors
# Ensure proper format (1, 1, samples)
if audio_tensor.dim() == 1:
audio_tensor = audio_tensor.unsqueeze(0).unsqueeze(0)
elif audio_tensor.dim() == 2:
audio_tensor = audio_tensor.unsqueeze(0)
# Ensure waveform is in Float32 for compatibility (VibeVoice may output BFloat16)
audio_output = audio_tensor.cpu()
if audio_output.dtype == torch.bfloat16:
audio_output = audio_output.to(torch.float32)
result = {
"waveform": audio_output,
"sample_rate": 24000
}
# Cache result if enabled (following ChatterBox pattern)
if enable_cache:
# Clone tensor to avoid autograd issues like ChatterBox does
# Cache only the waveform tensor, not the full dict
waveform_clone = result["waveform"].detach().clone() if result["waveform"].requires_grad else result["waveform"]
# print(f"🐛 VibeVoice ENGINE: CACHING result for audio_component '{stable_audio_component[:50]}...'")
cache_fn(text, audio_result=waveform_clone)
return result
else:
raise RuntimeError("VibeVoice failed to generate audio output")
except Exception as e:
logger.error(f"VibeVoice generation failed: {e}")
raise RuntimeError(f"Generation failed: {e}")
def generate_multi_speaker(self,
segments: List[Tuple[str, str]],
voice_mapping: Dict[str, np.ndarray],
**kwargs) -> Dict[str, Any]:
"""
Generate multi-speaker dialogue.
Args:
segments: List of (character, text) tuples
voice_mapping: Dict mapping character names to voice samples
**kwargs: Additional generation parameters
Returns:
Dict with combined audio
"""
# Convert segments to VibeVoice format
speaker_map = {}
speaker_voices = []
formatted_lines = []
for char, text in segments:
if char not in speaker_map:
speaker_idx = len(speaker_map)
speaker_map[char] = speaker_idx
speaker_voices.append(voice_mapping.get(char,
self._create_synthetic_voice_sample(speaker_idx)))
speaker_idx = speaker_map[char]
formatted_lines.append(f"Speaker {speaker_idx}: {text}")
# Join with newlines for multi-speaker format
formatted_text = "\n".join(formatted_lines)
# Generate with multi-speaker text
return self.generate_speech(formatted_text, speaker_voices, **kwargs)
def _generate_kugelaudio(
self,
text,
voice_samples,
cfg_scale,
seed,
use_sampling,
temperature,
top_p,
inference_steps,
max_new_tokens,
):
"""Handle generation specifically for KugelAudio"""
try:
device = next(self.model.parameters()).device
# Keep KugelAudio aligned with the VibeVoice UI instead of silently
# using the model config default (20 diffusion steps).
if hasattr(self.model, "set_ddpm_inference_steps"):
self.model.set_ddpm_inference_steps(num_steps=inference_steps)
# Prepare voice_prompt from voice samples (raw audio)
voice_prompt_audio = None
if voice_samples and voice_samples[0] is not None:
voice_prompt_audio = voice_samples[0]
logger.info(f"KugelAudio voice cloning: {len(voice_prompt_audio)/24000:.1f}s reference audio")
batch = self.processor(
text=text,
voice_prompt=voice_prompt_audio,
return_tensors="pt"
)
# Move inputs to device
batch = {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in batch.items()}
# Generate
with torch.no_grad():
gen_args = {
"cfg_scale": cfg_scale,
"max_new_tokens": max_new_tokens or 2048,
}
if use_sampling:
gen_args.update({
"do_sample": True,
"temperature": temperature,
"top_p": top_p,
})
else:
gen_args["do_sample"] = False
output = self.model.generate(**batch, **gen_args)
# Extract audio from KugelAudioGenerationOutput
if hasattr(output, 'speech_outputs') and output.speech_outputs:
audio_output = output.speech_outputs[0]
if audio_output is not None:
audio_output = audio_output.cpu().float()
# Ensure proper shape (1, 1, samples) for downstream
if audio_output.dim() == 1:
audio_output = audio_output.unsqueeze(0).unsqueeze(0)
elif audio_output.dim() == 2:
audio_output = audio_output.unsqueeze(0)
return {
"waveform": audio_output,
"sample_rate": 24000
}
logger.warning(f"KugelAudio generation produced no audio output")
return {
"waveform": torch.zeros(1, 1, 24000),
"sample_rate": 24000
}
except Exception as e:
logger.error(f"KugelAudio generation failed: {e}")
raise RuntimeError(f"KugelAudio generation failed: {e}")
def _is_kugelaudio_model(self, model_name: str, model_path: str) -> bool:
"""Detect if current model is KugelAudio"""
if is_kugelaudio_variant_name(model_name):
return True
# Check config.json if directory exists
if model_path and os.path.isdir(model_path):
config_file = os.path.join(model_path, "config.json")
if os.path.exists(config_file):
try:
import json
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
return config.get("model_type") == "kugelaudio"
except:
pass
return False
def _initialize_kugelaudio(self, model_path: str, model_name: str, device: str,
attention_mode: str, quantize_llm_4bit: bool):
"""Initialize KugelAudio model specifically"""
try:
from .kugelaudio_impl import KugelAudioForConditionalGenerationInference, KugelAudioProcessor
print(f"🔄 Loading KugelAudio model '{model_name}' on {device}...")
# Resolve device before building HF loading kwargs so quantization lands
# on the intended target instead of relying on a stale string value.
from utils.device import resolve_torch_device
device = resolve_torch_device(device)
# Use bfloat16 if available (matching standard VibeVoice)
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
model_kwargs = {
"torch_dtype": dtype,
"low_cpu_mem_usage": True,
"device_map": device if device != "auto" else "auto",
}
final_attention_mode = attention_mode
if attention_mode == "auto":
if SAGE_ATTENTION_AVAILABLE and SAGE_ATTENTION_FUNCTION is not None:
final_attention_mode = "sage"
print(" 🚀 Auto-selected SageAttention (GPU-optimized mixed-precision)")
else:
try:
import flash_attn # noqa: F401
final_attention_mode = "flash_attention_2"
print(" ✨ Auto-selected flash_attention_2")
except ImportError:
final_attention_mode = "sdpa"
print(" ⚡ Auto-selected sdpa (flash_attention_2 not available)")
elif attention_mode == "sage":
final_attention_mode = "sage"
print(" 🎯 Using SageAttention")
else:
print(f" 🧠 Using {attention_mode} attention")
# SageAttention is applied by patching the model after load, so the HF
# loader itself should stay on SDPA.
attn_implementation_for_load = "sdpa" if final_attention_mode == "sage" else final_attention_mode
if attn_implementation_for_load != "auto":
model_kwargs["attn_implementation"] = attn_implementation_for_load
if quantize_llm_4bit:
if str(device) == "cpu":
raise RuntimeError("KugelAudio 4-bit quantization requires CUDA; CPU quantization is not supported.")
from transformers import BitsAndBytesConfig
bnb_compute_dtype = dtype
if final_attention_mode == "sage":
bnb_compute_dtype = torch.float32
print(f" 📊 Using {final_attention_mode} with 4-bit: using {bnb_compute_dtype} compute dtype")
model_kwargs["quantization_config"] = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=bnb_compute_dtype,
)
model_kwargs["device_map"] = {"": 0} if str(device) == "cuda" else device
print(" 🗜️ 4-bit LLM quantization enabled for KugelAudio")
# Load model
self.model = KugelAudioForConditionalGenerationInference.from_pretrained(
model_path,
**model_kwargs,
)
# Load processor
self.processor = KugelAudioProcessor.from_pretrained(model_path)
if final_attention_mode == "sage":
if SAGE_ATTENTION_AVAILABLE and set_sage_attention:
print(f" 🎯 Applying SageAttention patch to model...")
try:
set_sage_attention(self.model)
print(f" ✅ SageAttention successfully applied")
except Exception as sage_error:
print(f" ⚠️ Failed to apply SageAttention: {sage_error}")
print(" 📌 Falling back to SDPA")
final_attention_mode = "sdpa"
else:
print(" ⚠️ SageAttention not available, using SDPA")
final_attention_mode = "sdpa"
else:
if restore_original_attention:
print(" 🔄 Restoring original attention (cleaning SageAttention patches)")
restore_original_attention(self.model)
self.model.eval()
# Store configuration
self.model_path = model_path
self.device = device
self._original_device = device
self.current_model_name = model_name
self._current_config = (model_name, device, attention_mode, quantize_llm_4bit)
self._quantize_llm_4bit = quantize_llm_4bit
self.attention_mode = final_attention_mode
print(f"✅ KugelAudio model '{model_name}' loaded successfully")
print(f" Device: {device}, Attention: {final_attention_mode}")
except Exception as e:
logger.error(f"Failed to load KugelAudio model: {e}")
raise RuntimeError(f"KugelAudio loading failed: {e}")
def cleanup(self):
"""Clean up resources"""
if self.model is not None:
del self.model
self.model = None
if self.processor is not None:
del self.processor
self.processor = None
# Force garbage collection
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
print("🧹 VibeVoice engine cleaned up")
def unload_models(self):
"""Unload models (called by ComfyUI's unload button)"""
self.cleanup()
self.current_model_name = None
print("📤 VibeVoice models unloaded")