first commit
This commit is contained in:
+44
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
requests
|
||||
numpy
|
||||
pydub
|
||||
torch
|
||||
Reference in New Issue
Block a user