diff --git a/__init__.py b/__init__.py index 7651938..ae76efa 100644 --- a/__init__.py +++ b/__init__.py @@ -11,8 +11,9 @@ from .nano_banana import NODE_CLASS_MAPPINGS as NANO_BANANA_MAPPINGS, NODE_DISPL from .gemini_tts import NODE_CLASS_MAPPINGS as GEMINI_TTS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as GEMINI_TTS_DISPLAY from .elevenlabs_tts import NODE_CLASS_MAPPINGS as ELEVENLABS_TTS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as ELEVENLABS_TTS_DISPLAY from .flux2_replicate import NODE_CLASS_MAPPINGS as FLUX2_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as FLUX2_DISPLAY +from .openai_tts import NODE_CLASS_MAPPINGS as OPENAI_TTS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as OPENAI_TTS_DISPLAY from .cleanup import NODE_CLASS_MAPPINGS as CLEANUP_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as CLEANUP_DISPLAY -NODE_CLASS_MAPPINGS = {**PRO_MAPPINGS, **GEMINI_MAPPINGS, **GEMINI_DIAR_MAPPINGS, **GPT_IMAGE_MAPPINGS, **IMAGEN_IMAGE_MAPPINGS, **IMAGEN_EDIT_MAPPINGS, **VEO_MAPPINGS, **VEO_GEMINI_MAPPINGS, **GEMINI_SEGMENT_MAPPINGS, **NANO_BANANA_MAPPINGS, **GEMINI_TTS_MAPPINGS, **ELEVENLABS_TTS_MAPPINGS, **FLUX2_MAPPINGS, **CLEANUP_MAPPINGS} -NODE_DISPLAY_NAME_MAPPINGS = {**PRO_DISPLAY, **GEMINI_DISPLAY, **GEMINI_DIAR_DISPLAY, **GPT_IMAGE_DISPLAY, **IMAGEN_IMAGE_DISPLAY, **IMAGEN_EDIT_DISPLAY, **VEO_DISPLAY, **VEO_GEMINI_DISPLAY, **GEMINI_SEGMENT_DISPLAY, **NANO_BANANA_DISPLAY, **GEMINI_TTS_DISPLAY, **ELEVENLABS_TTS_DISPLAY, **FLUX2_DISPLAY, **CLEANUP_DISPLAY} +NODE_CLASS_MAPPINGS = {**PRO_MAPPINGS, **GEMINI_MAPPINGS, **GEMINI_DIAR_MAPPINGS, **GPT_IMAGE_MAPPINGS, **IMAGEN_IMAGE_MAPPINGS, **IMAGEN_EDIT_MAPPINGS, **VEO_MAPPINGS, **VEO_GEMINI_MAPPINGS, **GEMINI_SEGMENT_MAPPINGS, **NANO_BANANA_MAPPINGS, **GEMINI_TTS_MAPPINGS, **ELEVENLABS_TTS_MAPPINGS, **FLUX2_MAPPINGS, **OPENAI_TTS_MAPPINGS, **CLEANUP_MAPPINGS} +NODE_DISPLAY_NAME_MAPPINGS = {**PRO_DISPLAY, **GEMINI_DISPLAY, **GEMINI_DIAR_DISPLAY, **GPT_IMAGE_DISPLAY, **IMAGEN_IMAGE_DISPLAY, **IMAGEN_EDIT_DISPLAY, **VEO_DISPLAY, **VEO_GEMINI_DISPLAY, **GEMINI_SEGMENT_DISPLAY, **NANO_BANANA_DISPLAY, **GEMINI_TTS_DISPLAY, **ELEVENLABS_TTS_DISPLAY, **FLUX2_DISPLAY, **OPENAI_TTS_DISPLAY, **CLEANUP_DISPLAY} __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/openai_tts.py b/openai_tts.py new file mode 100644 index 0000000..f8d3b0d --- /dev/null +++ b/openai_tts.py @@ -0,0 +1,106 @@ +import os +import io +import torch +import requests +import soundfile as sf + +class OpenAITTSNode: + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"multiline": True, "default": ""}), + "model": ([ + "gpt-4o-mini-tts", + "tts-1", + "tts-1-hd" + ],), + "voice": ([ + "alloy", + "ash", + "coral", + "echo", + "fable", + "onyx", + "nova", + "sage", + "shimmer" + ],), + "response_format": ([ + "mp3", + "opus", + "aac", + "flac", + "wav", + "pcm" + ],), + "speed": ("FLOAT", {"default": 1.0, "min": 0.25, "max": 4.0, "step": 0.01}), + "api_key": ("STRING", {"multiline": False, "default": ""}), + }, + "optional": { + "instructions": ("STRING", {"multiline": True, "default": ""}), + } + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio",) + FUNCTION = "generate_speech" + CATEGORY = "audio/generation" + + def generate_speech(self, text, api_key, model, voice, response_format, speed, instructions=""): + + if not text.strip(): + raise ValueError("Text input cannot be empty.") + + key = api_key.strip() or os.environ.get("OPENAI_API_KEY") + if not key: + raise ValueError("No API key provided. Set OPENAI_API_KEY environment variable or provide it in the node.") + + # Check if instructions are used with incompatible models + if instructions.strip() and model in ["tts-1", "tts-1-hd"]: + raise ValueError(f"Instructions parameter is not supported with model '{model}'. Please use 'gpt-4o-mini-tts' instead.") + + url = "https://api.openai.com/v1/audio/speech" + + headers = { + "Authorization": f"Bearer {key}", + "Content-Type": "application/json" + } + + data = { + "model": model, + "input": text, + "voice": voice, + "response_format": response_format, + "speed": speed + } + + # Add instructions only if provided and model supports it + if instructions.strip() and model not in ["tts-1", "tts-1-hd"]: + data["instructions"] = instructions + + response = requests.post(url, json=data, headers=headers) + + if response.status_code != 200: + raise Exception(f"OpenAI API Error: {response.status_code}, {response.text}") + + # Decode audio with soundfile + audio_buffer = io.BytesIO(response.content) + waveform, sample_rate = sf.read(audio_buffer, dtype='float32') + waveform = torch.from_numpy(waveform) + + # Ensure correct shape [channels, samples] + if waveform.dim() == 1: + waveform = waveform.unsqueeze(0) + else: + waveform = waveform.t() + + return ({"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate},) + + @classmethod + def IS_CHANGED(cls, **kwargs): + return f"{kwargs.get('text', '')}-{kwargs.get('voice', '')}-{kwargs.get('model', '')}" + +NODE_CLASS_MAPPINGS = {"OpenAITTSNode": OpenAITTSNode} +NODE_DISPLAY_NAME_MAPPINGS = {"OpenAITTSNode": "OpenAI Text-to-Speech"} \ No newline at end of file