From 2246ab9b2df9ccd6b0900737726cc085387d9237 Mon Sep 17 00:00:00 2001 From: Jeril Sebastian Date: Tue, 6 Jan 2026 11:00:31 +0530 Subject: [PATCH] first commit --- .gitignore | 44 +++++++++ LICENSE | 21 +++++ README.md | 97 ++++++++++++++++++++ __init__.py | 3 + node.py | 226 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + 6 files changed, 395 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 node.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a542d71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Secrets +api_keys.json +secrets.json +*.key \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3d47360 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 gabe-init + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3360d17 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# ComfyUI ElevenLabs TTS Node + +A custom node for ComfyUI that integrates ElevenLabs' text-to-speech API, enabling high-quality voice synthesis within your ComfyUI workflows. + +## Features + +- Pulls your ElevenLabs voices on demand (cached for 1 hour) and maps names → IDs automatically +- Built-in support for the shipped models: `eleven_multilingual_v2`, `eleven_english_sts_v2`, `eleven_turbo_v2` +- Voice tuning controls: stability, similarity boost, style, and speaker boost +- Text input flexibility: type in the node UI or feed text from another node (`input_text` override) +- Returns a ComfyUI `AUDIO` dict with waveform tensor + sample rate decoded from ElevenLabs MP3 responses + +## Installation + +1. Clone this repository into your ComfyUI `custom_nodes` folder: +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/yourusername/ComfyUI-ElevenLabs +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +``` + +3. Restart ComfyUI. + +## Setup + +You'll need an ElevenLabs API key (`ELEVENLABS_API_KEY`) set in your environment. Example for a shell session: +```bash +export ELEVENLABS_API_KEY=your_key +``` +The node reads this variable automatically; there is no API key input inside the UI. + +## Usage + +### Basic Text-to-Speech + +1. Add the "ElevenLabs TTS Node" to your workflow. +2. Type text in the node or wire a string input into `input_text` (wired input takes priority). +3. Pick a voice from the dropdown (list is fetched from your ElevenLabs account and cached). +4. Choose a model (defaults to multilingual v2). +5. Adjust stability, similarity boost, style, and speaker boost as desired. +6. Connect the audio output to preview or save nodes. + +### Inputs + +- **text**: Text box content to synthesize. +- **input_text** (optional): Upstream string input; overrides the text box when connected. +- **voice**: Dropdown of your ElevenLabs voices (deduped names, numbered when repeated). +- **model**: One of `eleven_multilingual_v2`, `eleven_english_sts_v2`, `eleven_turbo_v2`. +- **stability**: 0.0–1.0. +- **similarity_boost**: 0.0–1.0. +- **style**: 0.0–1.0. +- **use_speaker_boost**: Boolean toggle. + +### Output + +- **AUDIO**: `{"waveform": torch.Tensor(shape: 1 x channels x samples), "sample_rate": int}` + +## Notes & Troubleshooting + +- Voices are fetched from `https://api.elevenlabs.io/v1/voices` and cached for 1 hour. Missing/expired API keys log an error and return a silent placeholder waveform. +- Failures in TTS requests or MP3 decoding also return a 1-sample silent waveform and log the error. +- Some voices or models may depend on your ElevenLabs subscription tier. + +## Requirements + +- ComfyUI +- Python packages: requests, torch, numpy +- ElevenLabs API key +- Internet connection for API calls + +## License + +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..acd8f4c --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file diff --git a/node.py b/node.py new file mode 100644 index 0000000..bdde3a0 --- /dev/null +++ b/node.py @@ -0,0 +1,226 @@ +import io +import os +import time + +import numpy as np +import requests +import torch +from pydub import AudioSegment +import logging + +class ElevenLabsNode: + voices_cache = None + voices_map = {} + models_cache = None + last_fetch_time = 0 + cache_duration = 3600 # Cache duration in seconds (1 hour) + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(cls): + voices = cls.fetch_elevenlabs_voices() + models = cls.fetch_elevenlabs_models() + return { + "required": { + "text": ("STRING", { + "multiline": True, + "default": "Hello, how are you?" + }), + "voice": (voices,), + "model": (models,), + "stability": ("FLOAT", { + "default": 0.5, + "min": 0.0, + "max": 1.0, + "step": 0.1 + }), + "similarity_boost": ("FLOAT", { + "default": 0.8, + "min": 0.0, + "max": 1.0, + "step": 0.1 + }), + "style": ("FLOAT", { + "default": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.1 + }), + "use_speaker_boost": ("BOOLEAN", { + "default": True, + }), + }, + "optional": { + "input_text": ("STRING", {"forceInput": True}), + + } + } + + RETURN_TYPES = ("AUDIO",) + FUNCTION = "generate_speech" + CATEGORY = "ElevenLabs" + + @classmethod + def _get_api_key(cls): + api_key = os.getenv("ELEVENLABS_API_KEY", "").strip() + if not api_key: + logging.error("ELEVENLABS_API_KEY is not set. Please configure it in your environment.") + return None + return api_key + + @classmethod + def fetch_elevenlabs_voices(cls): + current_time = time.time() + if cls.voices_cache is None or (current_time - cls.last_fetch_time > cls.cache_duration): + voice_list = [] + voice_map = {} + api_key = cls._get_api_key() + if api_key is None: + cls.voices_cache = ["missing_api_key"] + cls.voices_map = {} + return cls.voices_cache + + # Fetch voices from the API + url = "https://api.elevenlabs.io/v1/voices" + try: + response = requests.get(url, headers={"xi-api-key": api_key}) + response.raise_for_status() + voices = response.json()["voices"] + name_counts = {} + for voice in voices: + base_name = voice["name"] + count = name_counts.get(base_name, 0) + 1 + name_counts[base_name] = count + display_name = base_name if count == 1 else f"{base_name} ({count})" + voice_list.append(display_name) + voice_map[display_name] = voice["voice_id"] + cls.voices_cache = voice_list + cls.voices_map = voice_map + cls.last_fetch_time = current_time + except requests.exceptions.RequestException as e: + logging.error(f"Error fetching voices: {e}") + if cls.voices_cache is None: + cls.voices_cache = ["error_fetching_voices"] + cls.voices_map = {} + return cls.voices_cache + + @classmethod + def fetch_elevenlabs_models(cls): + current_time = time.time() + if cls.models_cache is None or (current_time - cls.last_fetch_time > cls.cache_duration): + cls.models_cache = ["eleven_multilingual_v2", "eleven_english_sts_v2", "eleven_turbo_v2"] + cls.last_fetch_time = current_time + return cls.models_cache + + def ensure_3d_tensor(self, tensor): + if tensor.dim() == 1: + return tensor.unsqueeze(0).unsqueeze(0) + elif tensor.dim() == 2: + return tensor.unsqueeze(0) + elif tensor.dim() > 3: + return tensor.squeeze().unsqueeze(0) + return tensor + + def _load_audio_from_bytes(self, audio_bytes: bytes): + """Decode ElevenLabs MP3 response into torch waveform.""" + if not audio_bytes: + logging.error("Empty audio response received.") + return None, None + + try: + audio = AudioSegment.from_file(io.BytesIO(audio_bytes), format="mp3") + except Exception as e: + preview = audio_bytes[:80] + try: + preview_text = preview.decode("utf-8", errors="replace") + except Exception: + preview_text = str(preview) + logging.error(f"Error decoding MP3 audio: {e}. Preview: {preview_text}") + return None, None + + sample_rate = audio.frame_rate + sample_width = audio.sample_width + channels = audio.channels + + samples = np.array(audio.get_array_of_samples()) + if channels > 1: + samples = samples.reshape(-1, channels).T # channels x samples + else: + samples = samples.reshape(1, -1) + + max_val = float(2 ** (8 * sample_width - 1)) + waveform = torch.from_numpy(samples).unsqueeze(0).float() / max_val + waveform = self.ensure_3d_tensor(waveform) + + return waveform, sample_rate + + def generate_speech(self, text, voice, model, stability, similarity_boost, style, use_speaker_boost, input_text=None): + # Use input_text if provided, otherwise use the text from the textbox + final_text = input_text if input_text is not None else text + + voice_id = self.voices_map.get(voice) + if not voice_id: + logging.error(f"Voice ID not found for selection '{voice}'. Refreshing voices cache.") + self.fetch_elevenlabs_voices() + voice_id = self.voices_map.get(voice) + if not voice_id: + return ({"waveform": torch.zeros(1, 1, 1).float(), "sample_rate": 44100},) + + api_key = self._get_api_key() + if api_key is None: + return ({"waveform": torch.zeros(1, 1, 1).float(), "sample_rate": 44100},) + + headers = { + "Accept": "audio/mpeg", + "xi-api-key": api_key, + "Content-Type": "application/json" + } + + # Text-to-Speech + url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}" + payload = { + "text": final_text, + "model_id": model, + "voice_settings": { + "stability": stability, + "similarity_boost": similarity_boost, + "style": style, + "use_speaker_boost": use_speaker_boost + } + } + try: + response = requests.post(url, headers=headers, json=payload) + except requests.exceptions.RequestException as e: + logging.error(f"Error in text-to-speech: {str(e)}") + return ({"waveform": torch.zeros(1, 1, 1).float(), "sample_rate": 44100},) + + if response.status_code == 200: + waveform, sample_rate = self._load_audio_from_bytes(response.content) + if waveform is None or sample_rate is None: + return ({"waveform": torch.zeros(1, 1, 1).float(), "sample_rate": 44100},) + + # Ensure the waveform is 3D + if waveform.dim() != 3: + logging.info(f"Unexpected tensor dimension {waveform.dim()}. Reshaping to 3D.") + waveform = waveform.view(1, 1, -1) + + return ({"waveform": waveform, "sample_rate": sample_rate},) + else: + logging.error(f"API Error: {response.status_code} - {response.text}") + return ({"waveform": torch.zeros(1, 1, 1).float(), "sample_rate": 44100},) + + @classmethod + def IS_CHANGED(cls, text, voice, model, stability, similarity_boost, style, use_speaker_boost, input_text=None): + return (text, voice, model, stability, similarity_boost, style, use_speaker_boost, input_text) + +# Node class mappings +NODE_CLASS_MAPPINGS = { + "ElevenLabsNode": ElevenLabsNode +} + +# Node display name mappings +NODE_DISPLAY_NAME_MAPPINGS = { + "ElevenLabsNode": "ElevenLabs TTS Node" +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c819ce9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests +numpy +pydub +torch \ No newline at end of file