628 lines
26 KiB
Python
628 lines
26 KiB
Python
"""
|
|
ComfyUI Custom Nodes for ChatterboxTTS - Voice Edition
|
|
Enhanced with bundled ChatterBox support and improved chunking
|
|
SUPPORTS: Bundled ChatterBox (recommended) + System ChatterBox (fallback)
|
|
"""
|
|
|
|
import torch
|
|
import torchaudio
|
|
import numpy as np
|
|
import folder_paths
|
|
import os
|
|
import tempfile
|
|
import re
|
|
from typing import List
|
|
|
|
# Get the current node directory for bundled resources
|
|
NODE_DIR = os.path.dirname(__file__)
|
|
BUNDLED_CHATTERBOX_DIR = os.path.join(NODE_DIR, "chatterbox")
|
|
BUNDLED_MODELS_DIR = os.path.join(NODE_DIR, "models", "chatterbox")
|
|
|
|
# Debug: Print what we're trying to import
|
|
print("🔍 Attempting to import ChatterBox modules...")
|
|
print(f"📁 Node directory: {NODE_DIR}")
|
|
print(f"📁 Looking for bundled ChatterBox at: {BUNDLED_CHATTERBOX_DIR}")
|
|
print(f"📁 Looking for bundled models at: {BUNDLED_MODELS_DIR}")
|
|
|
|
# Smart import logic: Try bundled first, then system
|
|
CHATTERBOX_TTS_AVAILABLE = False
|
|
CHATTERBOX_VC_AVAILABLE = False
|
|
USING_BUNDLED_CHATTERBOX = False
|
|
|
|
# Try to import bundled ChatterBox first
|
|
try:
|
|
# Add the node directory to Python path temporarily for bundled imports
|
|
import sys
|
|
if NODE_DIR not in sys.path:
|
|
sys.path.insert(0, NODE_DIR)
|
|
|
|
from chatterbox.tts import ChatterboxTTS
|
|
from chatterbox.vc import ChatterboxVC
|
|
print("✅ Using BUNDLED ChatterBox from node folder")
|
|
CHATTERBOX_TTS_AVAILABLE = True
|
|
CHATTERBOX_VC_AVAILABLE = True
|
|
USING_BUNDLED_CHATTERBOX = True
|
|
|
|
except ImportError as bundled_error:
|
|
print(f"📦 Bundled ChatterBox not found: {bundled_error}")
|
|
|
|
# Try system-installed ChatterBox as fallback
|
|
try:
|
|
from chatterbox.tts import ChatterboxTTS
|
|
print("✅ ChatterboxTTS imported from system installation")
|
|
CHATTERBOX_TTS_AVAILABLE = True
|
|
except ImportError as e:
|
|
print(f"❌ System ChatterboxTTS import failed: {e}")
|
|
CHATTERBOX_TTS_AVAILABLE = False
|
|
|
|
try:
|
|
from chatterbox.vc import ChatterboxVC
|
|
print("✅ ChatterboxVC imported from system installation")
|
|
CHATTERBOX_VC_AVAILABLE = True
|
|
except ImportError as e:
|
|
print(f"❌ System ChatterboxVC import failed: {e}")
|
|
CHATTERBOX_VC_AVAILABLE = False
|
|
|
|
if CHATTERBOX_TTS_AVAILABLE and CHATTERBOX_VC_AVAILABLE:
|
|
print("✅ Using SYSTEM ChatterBox installation")
|
|
USING_BUNDLED_CHATTERBOX = False
|
|
|
|
CHATTERBOX_AVAILABLE = CHATTERBOX_TTS_AVAILABLE and CHATTERBOX_VC_AVAILABLE
|
|
|
|
if not CHATTERBOX_AVAILABLE:
|
|
print("💡 Creating dummy classes for missing ChatterBox components")
|
|
print("🎯 To fix this:")
|
|
print(" 1. Install ChatterBox: pip install chatterbox-tts")
|
|
print(" 2. OR place ChatterBox code in the node folder for bundled approach")
|
|
|
|
# Create dummy classes so ComfyUI doesn't crash
|
|
if not CHATTERBOX_TTS_AVAILABLE:
|
|
class ChatterboxTTS:
|
|
@classmethod
|
|
def from_pretrained(cls, device):
|
|
raise ImportError("ChatterboxTTS not available - install missing dependencies or add bundled version")
|
|
|
|
@classmethod
|
|
def from_local(cls, path, device):
|
|
raise ImportError("ChatterboxTTS not available - install missing dependencies or add bundled version")
|
|
|
|
if not CHATTERBOX_VC_AVAILABLE:
|
|
class ChatterboxVC:
|
|
@classmethod
|
|
def from_pretrained(cls, device):
|
|
raise ImportError("ChatterboxVC not available - install missing dependencies or add bundled version")
|
|
|
|
@classmethod
|
|
def from_local(cls, path, device):
|
|
raise ImportError("ChatterboxVC not available - install missing dependencies or add bundled version")
|
|
|
|
|
|
class ImprovedChatterBoxChunker:
|
|
"""Enhanced text chunker inspired by Orpheus TTS approach"""
|
|
|
|
@staticmethod
|
|
def split_into_chunks(text: str, max_chars: int = 400) -> List[str]:
|
|
"""
|
|
Split text into chunks with better sentence boundary handling.
|
|
Uses character-based limits like Orpheus TTS for more predictable chunk sizes.
|
|
"""
|
|
if not text.strip():
|
|
return []
|
|
|
|
# Clean and normalize text
|
|
text = re.sub(r'\s+', ' ', text.strip())
|
|
|
|
# If text is short enough, return as single chunk
|
|
if len(text) <= max_chars:
|
|
return [text]
|
|
|
|
# Split into sentences using robust regex (same as Orpheus)
|
|
sentences = re.split(r'(?<=[.!?])\s+', text)
|
|
|
|
chunks = []
|
|
current_chunk = ""
|
|
|
|
for sentence in sentences:
|
|
sentence = sentence.strip()
|
|
if not sentence:
|
|
continue
|
|
|
|
# If adding this sentence exceeds limit and we have content, start new chunk
|
|
if len(current_chunk) + len(sentence) + 1 > max_chars and current_chunk:
|
|
chunks.append(current_chunk.strip())
|
|
current_chunk = sentence
|
|
# If single sentence is too long, split it further
|
|
elif len(sentence) > max_chars:
|
|
# Add current chunk if not empty
|
|
if current_chunk:
|
|
chunks.append(current_chunk.strip())
|
|
current_chunk = ""
|
|
|
|
# Split long sentence by commas (Orpheus approach)
|
|
parts = re.split(r'(?<=,)\s+', sentence)
|
|
sub_chunk = ""
|
|
|
|
for part in parts:
|
|
if len(sub_chunk) + len(part) + 1 > max_chars:
|
|
if sub_chunk:
|
|
chunks.append(sub_chunk.strip())
|
|
sub_chunk = part
|
|
else:
|
|
# Even single part is too long - split arbitrarily
|
|
for i in range(0, len(part), max_chars):
|
|
chunk_part = part[i:i+max_chars].strip()
|
|
if chunk_part:
|
|
chunks.append(chunk_part)
|
|
else:
|
|
sub_chunk = sub_chunk + ", " + part if sub_chunk else part
|
|
|
|
# Set remaining as current chunk
|
|
if sub_chunk:
|
|
current_chunk = sub_chunk
|
|
else:
|
|
# Normal sentence - add to current chunk
|
|
current_chunk = current_chunk + " " + sentence if current_chunk else sentence
|
|
|
|
# Add final chunk
|
|
if current_chunk.strip():
|
|
chunks.append(current_chunk.strip())
|
|
|
|
return chunks
|
|
|
|
@staticmethod
|
|
def add_silence_padding(audio: torch.Tensor, duration_ms: int = 50, sample_rate: int = 22050) -> torch.Tensor:
|
|
"""Add brief silence between chunks to improve naturalness"""
|
|
silence_samples = int(duration_ms * sample_rate / 1000)
|
|
|
|
# Create silence tensor with same shape as audio tensor
|
|
if audio.dim() == 1:
|
|
# 1D audio tensor [samples]
|
|
silence = torch.zeros(silence_samples)
|
|
elif audio.dim() == 2:
|
|
# 2D audio tensor [channels, samples]
|
|
silence = torch.zeros(audio.shape[0], silence_samples)
|
|
else:
|
|
# Fallback - just match the last dimension
|
|
silence_shape = list(audio.shape)
|
|
silence_shape[-1] = silence_samples
|
|
silence = torch.zeros(*silence_shape)
|
|
|
|
return torch.cat([audio, silence], dim=-1)
|
|
|
|
|
|
def find_chatterbox_models():
|
|
"""
|
|
Smart model path detection with priority order:
|
|
1. Bundled models in node folder (best for portability)
|
|
2. ComfyUI models folder (standard location)
|
|
3. Let ChatterBox download from HuggingFace (requires auth)
|
|
"""
|
|
model_paths = []
|
|
|
|
# 1. Check for bundled models in node folder
|
|
if os.path.exists(BUNDLED_MODELS_DIR) and os.listdir(BUNDLED_MODELS_DIR):
|
|
model_paths.append(("bundled", BUNDLED_MODELS_DIR))
|
|
print(f"📦 Found bundled models at: {BUNDLED_MODELS_DIR}")
|
|
|
|
# 2. Check ComfyUI models folder
|
|
comfyui_model_path = os.path.join(folder_paths.models_dir, "TTS", "chatterbox")
|
|
if os.path.exists(comfyui_model_path) and os.listdir(comfyui_model_path):
|
|
model_paths.append(("comfyui", comfyui_model_path))
|
|
print(f"📁 Found ComfyUI models at: {comfyui_model_path}")
|
|
|
|
# 3. HuggingFace download as fallback
|
|
model_paths.append(("huggingface", None))
|
|
|
|
return model_paths
|
|
|
|
|
|
print("🔍 Defining ChatterboxTTSNode class with enhanced chunking...")
|
|
|
|
class ChatterboxTTSNode:
|
|
"""
|
|
Enhanced Text-to-Speech node using ChatterboxTTS - Voice Edition
|
|
SUPPORTS BUNDLED CHATTERBOX + Enhanced Chunking
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"text": ("STRING", {
|
|
"multiline": True,
|
|
"default": "Hello! This is the enhanced ChatterboxTTS with bundled support and improved chunking. It can handle very long texts by intelligently splitting them into smaller segments."
|
|
}),
|
|
"device": (["auto", "cuda", "cpu"], {"default": "auto"}),
|
|
"exaggeration": ("FLOAT", {
|
|
"default": 0.5,
|
|
"min": 0.25,
|
|
"max": 2.0,
|
|
"step": 0.05
|
|
}),
|
|
"temperature": ("FLOAT", {
|
|
"default": 0.8,
|
|
"min": 0.05,
|
|
"max": 5.0,
|
|
"step": 0.05
|
|
}),
|
|
"cfg_weight": ("FLOAT", {
|
|
"default": 0.5,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.05
|
|
}),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 2**32 - 1}),
|
|
},
|
|
"optional": {
|
|
"reference_audio": ("AUDIO",),
|
|
"audio_prompt_path": ("STRING", {"default": ""}),
|
|
# ENHANCED CHUNKING CONTROLS - ALL OPTIONAL FOR BACKWARD COMPATIBILITY
|
|
"enable_chunking": ("BOOLEAN", {"default": True}),
|
|
"max_chars_per_chunk": ("INT", {"default": 400, "min": 100, "max": 1000, "step": 50}),
|
|
"chunk_combination_method": (["auto", "concatenate", "silence_padding", "crossfade"], {"default": "auto"}),
|
|
"silence_between_chunks_ms": ("INT", {"default": 100, "min": 0, "max": 500, "step": 25}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("AUDIO", "STRING")
|
|
RETURN_NAMES = ("audio", "generation_info")
|
|
FUNCTION = "generate_speech"
|
|
CATEGORY = "ChatterBox Voice"
|
|
|
|
def __init__(self):
|
|
self.model = None
|
|
self.device = None
|
|
self.chunker = ImprovedChatterBoxChunker()
|
|
self.model_source = None # Track where models are loaded from
|
|
|
|
def load_model(self, device):
|
|
if not CHATTERBOX_TTS_AVAILABLE:
|
|
raise ImportError("ChatterboxTTS not available - check installation or add bundled version")
|
|
|
|
if device == "auto":
|
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
if self.model is None or self.device != device:
|
|
print(f"Loading ChatterboxTTS model on {device}...")
|
|
|
|
# Get available model paths in priority order
|
|
model_paths = find_chatterbox_models()
|
|
|
|
model_loaded = False
|
|
for source, path in model_paths:
|
|
try:
|
|
if source == "bundled":
|
|
print(f"📦 Loading from bundled models: {path}")
|
|
self.model = ChatterboxTTS.from_local(path, device)
|
|
self.model_source = "bundled"
|
|
model_loaded = True
|
|
break
|
|
elif source == "comfyui":
|
|
print(f"📁 Loading from ComfyUI models: {path}")
|
|
self.model = ChatterboxTTS.from_local(path, device)
|
|
self.model_source = "comfyui"
|
|
model_loaded = True
|
|
break
|
|
elif source == "huggingface":
|
|
print("🌐 Loading from Hugging Face (requires authentication)...")
|
|
self.model = ChatterboxTTS.from_pretrained(device)
|
|
self.model_source = "huggingface"
|
|
model_loaded = True
|
|
break
|
|
except Exception as e:
|
|
print(f"❌ Failed to load from {source}: {e}")
|
|
continue
|
|
|
|
if not model_loaded:
|
|
raise ImportError("Failed to load ChatterboxTTS from any source")
|
|
|
|
self.device = device
|
|
print(f"✅ ChatterboxTTS model loaded from {self.model_source}!")
|
|
|
|
def process_audio_chunk(self, chunk_text: str, audio_prompt: str, exaggeration: float,
|
|
temperature: float, cfg_weight: float) -> torch.Tensor:
|
|
"""Process a single text chunk into audio"""
|
|
return self.model.generate(
|
|
chunk_text,
|
|
audio_prompt_path=audio_prompt,
|
|
exaggeration=exaggeration,
|
|
temperature=temperature,
|
|
cfg_weight=cfg_weight
|
|
)
|
|
|
|
def add_crossfade(self, audio1: torch.Tensor, audio2: torch.Tensor,
|
|
fade_duration: float = 0.1) -> torch.Tensor:
|
|
"""Add crossfade between two audio segments"""
|
|
fade_samples = int(fade_duration * self.model.sr)
|
|
|
|
if audio1.size(-1) < fade_samples or audio2.size(-1) < fade_samples:
|
|
return torch.cat([audio1, audio2], dim=-1)
|
|
|
|
fade_out = torch.linspace(1.0, 0.0, fade_samples)
|
|
fade_in = torch.linspace(0.0, 1.0, fade_samples)
|
|
|
|
audio1_end = audio1[..., -fade_samples:] * fade_out
|
|
audio2_start = audio2[..., :fade_samples] * fade_in
|
|
crossfaded = audio1_end + audio2_start
|
|
|
|
return torch.cat([
|
|
audio1[..., :-fade_samples],
|
|
crossfaded,
|
|
audio2[..., fade_samples:]
|
|
], dim=-1)
|
|
|
|
def combine_audio_chunks(self, audio_segments: List[torch.Tensor], method: str,
|
|
silence_ms: int, text_length: int) -> torch.Tensor:
|
|
"""Combine audio segments using specified method"""
|
|
if len(audio_segments) == 1:
|
|
return audio_segments[0]
|
|
|
|
# Auto-select best method based on text length
|
|
if method == "auto":
|
|
if text_length > 1000: # Very long text
|
|
method = "silence_padding"
|
|
elif text_length > 500: # Medium text
|
|
method = "crossfade"
|
|
else: # Short text
|
|
method = "concatenate"
|
|
print(f"🤖 Auto-selected combination method: {method}")
|
|
|
|
if method == "concatenate":
|
|
print("🔗 Using simple concatenation")
|
|
return torch.cat(audio_segments, dim=-1)
|
|
|
|
elif method == "silence_padding":
|
|
print(f"🔗 Adding {silence_ms}ms silence between chunks")
|
|
combined = audio_segments[0]
|
|
for i in range(1, len(audio_segments)):
|
|
combined = self.chunker.add_silence_padding(
|
|
combined, silence_ms, self.model.sr
|
|
)
|
|
combined = torch.cat([combined, audio_segments[i]], dim=-1)
|
|
return combined
|
|
|
|
elif method == "crossfade":
|
|
print("🔗 Using crossfade blending")
|
|
combined = audio_segments[0]
|
|
for i in range(1, len(audio_segments)):
|
|
combined = self.add_crossfade(combined, audio_segments[i])
|
|
return combined
|
|
|
|
else:
|
|
# Fallback to concatenation
|
|
return torch.cat(audio_segments, dim=-1)
|
|
|
|
def generate_speech(self, text, device, exaggeration, temperature, cfg_weight, seed,
|
|
reference_audio=None, audio_prompt_path="",
|
|
enable_chunking=True, max_chars_per_chunk=400,
|
|
chunk_combination_method="auto", silence_between_chunks_ms=100):
|
|
|
|
self.load_model(device)
|
|
|
|
# Handle None/empty values for backward compatibility
|
|
if enable_chunking is None:
|
|
enable_chunking = True
|
|
if max_chars_per_chunk is None or max_chars_per_chunk < 100:
|
|
max_chars_per_chunk = 400
|
|
if chunk_combination_method is None or chunk_combination_method == "":
|
|
chunk_combination_method = "auto"
|
|
if silence_between_chunks_ms is None or silence_between_chunks_ms == "":
|
|
silence_between_chunks_ms = 100
|
|
|
|
# Set seed for reproducibility
|
|
if seed != 0:
|
|
torch.manual_seed(seed)
|
|
torch.cuda.manual_seed(seed)
|
|
np.random.seed(seed)
|
|
|
|
# Handle reference audio input
|
|
audio_prompt = None
|
|
if reference_audio is not None:
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
|
|
waveform = reference_audio["waveform"]
|
|
if waveform.dim() == 3:
|
|
waveform = waveform.squeeze(0)
|
|
torchaudio.save(tmp_file.name, waveform, reference_audio["sample_rate"])
|
|
audio_prompt = tmp_file.name
|
|
elif audio_prompt_path and os.path.exists(audio_prompt_path):
|
|
audio_prompt = audio_prompt_path
|
|
|
|
# Determine if chunking is needed
|
|
text_length = len(text)
|
|
|
|
if not enable_chunking or text_length <= max_chars_per_chunk:
|
|
print(f"📝 Processing single chunk: {text_length} characters")
|
|
wav = self.process_audio_chunk(text, audio_prompt, exaggeration, temperature, cfg_weight)
|
|
info = f"Generated {wav.size(-1) / self.model.sr:.1f}s audio from {text_length} characters (single chunk, {self.model_source} models)"
|
|
else:
|
|
# Split into chunks using improved chunker
|
|
chunks = self.chunker.split_into_chunks(text, max_chars_per_chunk)
|
|
print(f"📝 Processing {len(chunks)} chunks from {text_length} characters")
|
|
print(f" Max chars per chunk: {max_chars_per_chunk}")
|
|
print(f" Combination method: {chunk_combination_method}")
|
|
|
|
# Process each chunk
|
|
audio_segments = []
|
|
for i, chunk in enumerate(chunks):
|
|
chunk_length = len(chunk)
|
|
print(f"🎤 Chunk {i+1}/{len(chunks)}: {chunk_length} chars")
|
|
print(f" Preview: {chunk[:60]}{'...' if len(chunk) > 60 else ''}")
|
|
|
|
chunk_audio = self.process_audio_chunk(
|
|
chunk, audio_prompt, exaggeration, temperature, cfg_weight
|
|
)
|
|
audio_segments.append(chunk_audio)
|
|
|
|
# Combine audio segments
|
|
print(f"🔗 Combining {len(audio_segments)} audio segments")
|
|
wav = self.combine_audio_chunks(
|
|
audio_segments, chunk_combination_method, silence_between_chunks_ms, text_length
|
|
)
|
|
|
|
# Generate info
|
|
total_duration = wav.size(-1) / self.model.sr
|
|
avg_chunk_size = text_length // len(chunks)
|
|
info = f"Generated {total_duration:.1f}s audio from {text_length} characters using {len(chunks)} chunks (avg {avg_chunk_size} chars/chunk, {self.model_source} models)"
|
|
|
|
# Clean up temporary file
|
|
if reference_audio is not None and audio_prompt:
|
|
try:
|
|
os.unlink(audio_prompt)
|
|
except:
|
|
pass
|
|
|
|
# Return audio in ComfyUI format
|
|
return (
|
|
{
|
|
"waveform": wav.unsqueeze(0), # Add batch dimension
|
|
"sample_rate": self.model.sr
|
|
},
|
|
info
|
|
)
|
|
|
|
print("✅ ChatterboxTTSNode class defined")
|
|
print("🔍 Defining ChatterboxVCNode class...")
|
|
|
|
class ChatterboxVCNode:
|
|
"""
|
|
Voice Conversion node using ChatterboxVC - Voice Edition
|
|
SUPPORTS BUNDLED CHATTERBOX
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"source_audio": ("AUDIO",),
|
|
"target_audio": ("AUDIO",),
|
|
"device": (["auto", "cuda", "cpu"], {"default": "auto"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("AUDIO",)
|
|
RETURN_NAMES = ("converted_audio",)
|
|
FUNCTION = "convert_voice"
|
|
CATEGORY = "ChatterBox Voice"
|
|
|
|
def __init__(self):
|
|
self.model = None
|
|
self.device = None
|
|
self.model_source = None
|
|
|
|
def load_model(self, device):
|
|
if not CHATTERBOX_VC_AVAILABLE:
|
|
raise ImportError("ChatterboxVC not available - check installation or add bundled version")
|
|
|
|
if device == "auto":
|
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
|
|
if self.model is None or self.device != device:
|
|
print(f"Loading ChatterboxVC model on {device}...")
|
|
|
|
# Get available model paths in priority order (same logic as TTS)
|
|
model_paths = find_chatterbox_models()
|
|
|
|
model_loaded = False
|
|
for source, path in model_paths:
|
|
try:
|
|
if source == "bundled":
|
|
print(f"📦 Loading VC from bundled models: {path}")
|
|
self.model = ChatterboxVC.from_local(path, device)
|
|
self.model_source = "bundled"
|
|
model_loaded = True
|
|
break
|
|
elif source == "comfyui":
|
|
print(f"📁 Loading VC from ComfyUI models: {path}")
|
|
self.model = ChatterboxVC.from_local(path, device)
|
|
self.model_source = "comfyui"
|
|
model_loaded = True
|
|
break
|
|
elif source == "huggingface":
|
|
print("🌐 Loading VC from Hugging Face (requires authentication)...")
|
|
self.model = ChatterboxVC.from_pretrained(device)
|
|
self.model_source = "huggingface"
|
|
model_loaded = True
|
|
break
|
|
except Exception as e:
|
|
print(f"❌ Failed to load VC from {source}: {e}")
|
|
continue
|
|
|
|
if not model_loaded:
|
|
raise ImportError("Failed to load ChatterboxVC from any source")
|
|
|
|
self.device = device
|
|
print(f"✅ ChatterboxVC model loaded from {self.model_source}!")
|
|
|
|
def convert_voice(self, source_audio, target_audio, device):
|
|
self.load_model(device)
|
|
|
|
# Save audio to temporary files
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as source_tmp:
|
|
source_waveform = source_audio["waveform"]
|
|
if source_waveform.dim() == 3:
|
|
source_waveform = source_waveform.squeeze(0) # Remove batch dimension if present
|
|
torchaudio.save(source_tmp.name, source_waveform, source_audio["sample_rate"])
|
|
source_path = source_tmp.name
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as target_tmp:
|
|
target_waveform = target_audio["waveform"]
|
|
if target_waveform.dim() == 3:
|
|
target_waveform = target_waveform.squeeze(0) # Remove batch dimension if present
|
|
torchaudio.save(target_tmp.name, target_waveform, target_audio["sample_rate"])
|
|
target_path = target_tmp.name
|
|
|
|
try:
|
|
# Perform voice conversion
|
|
wav = self.model.generate(
|
|
source_path,
|
|
target_voice_path=target_path
|
|
)
|
|
|
|
# Clean up temporary files
|
|
os.unlink(source_path)
|
|
os.unlink(target_path)
|
|
|
|
# Return audio in ComfyUI format
|
|
return ({
|
|
"waveform": wav.unsqueeze(0), # Add batch dimension
|
|
"sample_rate": self.model.sr
|
|
},)
|
|
|
|
except Exception as e:
|
|
# Clean up on error
|
|
try:
|
|
os.unlink(source_path)
|
|
os.unlink(target_path)
|
|
except:
|
|
pass
|
|
raise e
|
|
|
|
print("✅ ChatterboxVCNode class defined")
|
|
|
|
# Print setup summary
|
|
print("\n" + "="*60)
|
|
print("🎉 CHATTERBOX VOICE NODES LOADED SUCCESSFULLY!")
|
|
print("="*60)
|
|
if USING_BUNDLED_CHATTERBOX:
|
|
print("📦 Using BUNDLED ChatterBox (self-contained)")
|
|
elif CHATTERBOX_AVAILABLE:
|
|
print("🔧 Using SYSTEM ChatterBox installation")
|
|
else:
|
|
print("❌ ChatterBox not available - install or bundle required")
|
|
|
|
print(f"📁 Node directory: {NODE_DIR}")
|
|
print(f"📁 Bundled ChatterBox: {os.path.exists(BUNDLED_CHATTERBOX_DIR)}")
|
|
print(f"📁 Bundled models: {os.path.exists(BUNDLED_MODELS_DIR)}")
|
|
print("="*60)
|
|
print()
|
|
|
|
# Node mappings for ComfyUI - UPDATED: Unique names to avoid conflicts
|
|
NODE_CLASS_MAPPINGS = {
|
|
"ChatterBoxVoiceTTS": ChatterboxTTSNode,
|
|
"ChatterBoxVoiceVC": ChatterboxVCNode,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"ChatterBoxVoiceTTS": "🎤 ChatterBox Voice TTS",
|
|
"ChatterBoxVoiceVC": "🔄 ChatterBox Voice Conversion",
|
|
} |