# ComfyUI-FireRedTTS V0.1.1 # A ComfyUI integration for FireRedTTS‑2, a real-time multi-speaker TTS system enabling high-quality, # emotionally expressive dialogue and monologue synthesis. Leveraging a streaming architecture and context-aware prosody modeling, # it supports natural speaker turns and stable long-form generation, ideal for interactive chat and podcast applications. # # Models License Notice: # - FireRedTTS: Apache-2.0 License (https://huggingface.co/FireRedTeam/FireRedTTS2) # # This integration script follows GPL-3.0 License. # When using or modifying this code, please respect both the original model licenses # and this integration's license terms. # # Source: https://github.com/1038lab/ComfyUI-FireRedTTS import os import os.path as osp import torch import torchaudio import tempfile import shutil import folder_paths from huggingface_hub import snapshot_download from pathlib import Path from typing import Optional, List # Import the FireRedTTS2 model from fireredtts2.fireredtts2 import FireRedTTS2 # Setup temporary directory now_dir = osp.dirname(osp.abspath(__file__)) tmp_dir = osp.join(now_dir, "tmp") def get_device(): if torch.cuda.is_available(): device_name = torch.cuda.get_device_name(0) print(f"[INFO] CUDA available - Using GPU: {device_name}") return "cuda" elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available(): print("[INFO] MPS available - Using Apple Silicon GPU") return "mps" else: print("[INFO] Using CPU (no GPU acceleration available)") return "cpu" device = get_device() dialogue_model = None monologue_model = None model_path = None def clear_gpu_memory(): if torch.cuda.is_available(): torch.cuda.empty_cache() torch.cuda.synchronize() def check_gpu_memory(): if device == "cuda" and torch.cuda.is_available(): total_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3 allocated_memory = torch.cuda.memory_allocated(0) / 1024**3 cached_memory = torch.cuda.memory_reserved(0) / 1024**3 print(f"[INFO] GPU Memory - Total: {total_memory:.1f}GB, Allocated: {allocated_memory:.1f}GB, Cached: {cached_memory:.1f}GB") return total_memory, allocated_memory, cached_memory return None, None, None def safe_generate_with_fallback(model, generate_func, *args, **kwargs): try: clear_gpu_memory() return generate_func(*args, **kwargs) except RuntimeError as e: if "CUDA" in str(e) or "out of memory" in str(e).lower(): print(f"[WARNING] GPU memory error: {str(e)}") print("[INFO] Attempting to clear GPU cache and retry...") clear_gpu_memory() try: return generate_func(*args, **kwargs) except RuntimeError as e2: print(f"[WARNING] GPU retry failed: {str(e2)}") print("[INFO] Falling back to CPU generation...") global dialogue_model, monologue_model if hasattr(model, 'device'): original_device = model.device model.to('cpu') try: result = generate_func(*args, **kwargs) model.to(original_device) return result except Exception as e3: print(f"[ERROR] CPU fallback also failed: {str(e3)}") raise e3 else: raise e2 else: raise e def get_model_path(): global model_path if model_path is None: models_dir = folder_paths.models_dir tts_dir = os.path.join(models_dir, "TTS") fireredtts2_dir = os.path.join(tts_dir, "FireRedTTS2") if not os.path.exists(fireredtts2_dir) or not os.path.exists(os.path.join(fireredtts2_dir, "config_llm.json")): print("[INFO] Downloading FireRedTTS2 model...") try: os.makedirs(tts_dir, exist_ok=True) os.makedirs(fireredtts2_dir, exist_ok=True) snapshot_download( repo_id="FireRedTeam/FireRedTTS2", local_dir=fireredtts2_dir, local_dir_use_symlinks=False ) print("[INFO] FireRedTTS2 model downloaded successfully!") except Exception as e: print(f"[ERROR] Failed to download FireRedTTS2 model: {str(e)}") raise e else: print("[INFO] Using existing FireRedTTS2 model") model_path = fireredtts2_dir return model_path def get_dialogue_model(): global dialogue_model if dialogue_model is None: try: model_dir = get_model_path() print(f"[INFO] Loading dialogue model on {device}...") check_gpu_memory() dialogue_model = FireRedTTS2( pretrained_dir=model_dir, gen_type="dialogue", device=device ) print("[INFO] Dialogue model loaded successfully!") check_gpu_memory() except Exception as e: print(f"[ERROR] Failed to load dialogue model: {str(e)}") import traceback print(f"[ERROR] Full traceback: {traceback.format_exc()}") raise e return dialogue_model def get_monologue_model(): global monologue_model if monologue_model is None: try: model_dir = get_model_path() print(f"[INFO] Loading monologue model on {device}...") monologue_model = FireRedTTS2( pretrained_dir=model_dir, gen_type="monologue", device=device ) print("[INFO] Monologue model loaded successfully!") except Exception as e: print(f"[ERROR] Failed to load monologue model: {str(e)}") raise e return monologue_model def parse_multiline_string(text: str) -> List[str]: if not text or not text.strip(): return [] lines = [line.strip() for line in text.strip().split('\n')] return [line for line in lines if line] def to_comfyui_audio(tensor: torch.Tensor, sample_rate: int = 24000): if tensor.is_cuda: tensor = tensor.cpu() if tensor.dim() == 1: tensor = tensor.unsqueeze(0).unsqueeze(0) elif tensor.dim() == 2: tensor = tensor.unsqueeze(0) result = {"waveform": tensor, "sample_rate": sample_rate} return result def parse_dialogue_text(text_list_str: str) -> List[str]: if not text_list_str or not text_list_str.strip(): return [] lines = parse_multiline_string(text_list_str) parsed_lines = [] for i, line in enumerate(lines): has_speaker_label = any(f"[S{j}]" in line for j in range(1, 3)) if not has_speaker_label: speaker_num = (i % 2) + 1 formatted_line = f"[S{speaker_num}]{line}" print(f"[INFO] Auto-formatted line {i+1}: Added [S{speaker_num}] to '{line[:50]}...'") parsed_lines.append(formatted_line) else: parsed_lines.append(line) return parsed_lines class FireRedTTS2_Dialogue: @classmethod def INPUT_TYPES(cls): return { "required": { "text_list": ("STRING", {"multiline": True, "default": "[S1]Hello, how are you?\n[S2]I'm doing great, thanks!\n[S1]That's wonderful to hear."}), "temperature": ("FLOAT", {"default": 0.9, "min": 0.1, "max": 2.0, "step": 0.01}), "topk": ("INT", {"default": 30, "min": 1, "max": 100}), }, "optional": { "S1": ("AUDIO",), "S1_text": ("STRING", {"multiline": False, "default": ""}), "S2": ("AUDIO",), "S2_text": ("STRING", {"multiline": False, "default": ""}), } } RETURN_TYPES = ("AUDIO",) RETURN_NAMES = ("audio",) FUNCTION = "generate_dialogue" CATEGORY = "🧪AILab/🔊TTS/FireRedTTS" def generate_dialogue(self, text_list, temperature, topk, S1=None, S1_text="", S2=None, S2_text=""): os.makedirs(tmp_dir, exist_ok=True) try: model = get_dialogue_model() text_lines = parse_dialogue_text(text_list) if not text_lines: raise ValueError("Dialogue text cannot be empty") prompt_wavs = [] prompt_texts = [] speakers = [ ("[S1]", S1, S1_text), ("[S2]", S2, S2_text), ] for speaker_label, prompt_wav, prompt_text in speakers: if prompt_wav is not None and prompt_text.strip(): with tempfile.NamedTemporaryFile(delete=False, suffix=".wav", dir=tmp_dir) as f: waveform = prompt_wav["waveform"].squeeze(0) torchaudio.save(f.name, waveform, prompt_wav["sample_rate"]) prompt_wavs.append(f.name) prompt_texts.append(f"{speaker_label}{prompt_text.strip()}") final_prompt_wavs = prompt_wavs if prompt_wavs else None final_prompt_texts = prompt_texts if prompt_texts else None if final_prompt_wavs: print(f"[INFO] Using {len(final_prompt_wavs)} speaker prompts for voice cloning") else: print("[INFO] No prompts provided, using random voices") print(f"[INFO] Generating dialogue with {len(text_lines)} text segments") print(f"[INFO] Using device: {device}") audio_tensor = safe_generate_with_fallback( model, model.generate_dialogue, text_list=text_lines, prompt_wav_list=final_prompt_wavs, prompt_text_list=final_prompt_texts, temperature=temperature, topk=topk ) res_audio = to_comfyui_audio(audio_tensor, 24000) if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) print("[INFO] Dialogue generation completed successfully") return (res_audio,) except Exception as e: print(f"[ERROR] FireRedTTS2 Dialogue generation failed: {str(e)}") import traceback print(f"[ERROR] Full traceback: {traceback.format_exc()}") if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) empty_audio = torch.zeros(1, 1, 1000) return (to_comfyui_audio(empty_audio.squeeze(), 24000),) class FireRedTTS2MonologueNode: @classmethod def INPUT_TYPES(cls): return { "required": { "text": ("STRING", {"multiline": True, "default": "这是一段测试文本"}), "temperature": ("FLOAT", {"default": 0.75, "min": 0.1, "max": 2.0, "step": 0.01}), "topk": ("INT", {"default": 20, "min": 1, "max": 100}), }, "optional": { "prompt_wav": ("AUDIO",), "prompt_text": ("STRING", {"multiline": True, "default": ""}), } } RETURN_TYPES = ("AUDIO",) RETURN_NAMES = ("audio",) FUNCTION = "generate_monologue" CATEGORY = "🧪AILab/🔊TTS/FireRedTTS" def generate_monologue(self, text, temperature, topk, prompt_wav=None, prompt_text=""): os.makedirs(tmp_dir, exist_ok=True) try: model = get_monologue_model() if not text.strip(): raise ValueError("Text cannot be empty") prompt_wav_path = None prompt_text_content = None if prompt_wav is not None and prompt_text.strip(): with tempfile.NamedTemporaryFile(delete=False, suffix=".wav", dir=tmp_dir) as f: waveform = prompt_wav["waveform"].squeeze(0) torchaudio.save(f.name, waveform, prompt_wav["sample_rate"]) prompt_wav_path = f.name prompt_text_content = prompt_text.strip() print(f"[INFO] Using voice cloning with reference audio and text") else: print(f"[INFO] Using random voice generation (no prompts provided)") print(f"[INFO] Generating monologue for text: {text[:50]}...") print(f"[INFO] Parameters - temperature: {temperature}, topk: {topk}") audio_tensor = safe_generate_with_fallback( model, model.generate_monologue, text=text, prompt_wav=prompt_wav_path, prompt_text=prompt_text_content, temperature=temperature, topk=topk ) print(f"[INFO] Generated audio tensor shape: {audio_tensor.shape}") res_audio = to_comfyui_audio(audio_tensor, 24000) if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) print("[INFO] Monologue generation completed successfully") return (res_audio,) except Exception as e: print(f"[ERROR] FireRedTTS2 Monologue generation failed: {str(e)}") if os.path.exists(tmp_dir): shutil.rmtree(tmp_dir) empty_audio = torch.zeros(1, 1, 1000) return (to_comfyui_audio(empty_audio.squeeze(), 24000),) NODE_CLASS_MAPPINGS = { "FireRedTTS2_Dialogue": FireRedTTS2_Dialogue, "FireRedTTS2MonologueNode": FireRedTTS2MonologueNode, } NODE_DISPLAY_NAME_MAPPINGS = { "FireRedTTS2_Dialogue": "FireRedTTS2 Dialogue", "FireRedTTS2MonologueNode": "FireRedTTS2 Monologue", }