diff --git a/__init__.py b/__init__.py index c501b35..e545377 100644 --- a/__init__.py +++ b/__init__.py @@ -7,9 +7,9 @@ from .imagen_edit import NODE_CLASS_MAPPINGS as IMAGEN_EDIT_MAPPINGS, NODE_DISPL from .veo import NODE_CLASS_MAPPINGS as VEO_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as VEO_DISPLAY from .gemini_segment import NODE_CLASS_MAPPINGS as GEMINI_SEGMENT_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as GEMINI_SEGMENT_DISPLAY from .nano_banana import NODE_CLASS_MAPPINGS as NANO_BANANA_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as NANO_BANANA_DISPLAY +from .gemini_tts import NODE_CLASS_MAPPINGS as GEMINI_TTS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as GEMINI_TTS_DISPLAY -# Combine both mappings -NODE_CLASS_MAPPINGS = {**PRO_MAPPINGS, **MAX_MAPPINGS, **GEMINI_MAPPINGS, **GPT_IMAGE_MAPPINGS, **IMAGEN_IMAGE_MAPPINGS, **IMAGEN_EDIT_MAPPINGS, **VEO_MAPPINGS, **GEMINI_SEGMENT_MAPPINGS, **NANO_BANANA_MAPPINGS} -NODE_DISPLAY_NAME_MAPPINGS = {**PRO_DISPLAY, **MAX_DISPLAY, **GEMINI_DISPLAY, **GPT_IMAGE_DISPLAY, **IMAGEN_IMAGE_DISPLAY, **IMAGEN_EDIT_DISPLAY, **VEO_DISPLAY, **GEMINI_SEGMENT_DISPLAY, **NANO_BANANA_DISPLAY} +NODE_CLASS_MAPPINGS = {**PRO_MAPPINGS, **MAX_MAPPINGS, **GEMINI_MAPPINGS, **GPT_IMAGE_MAPPINGS, **IMAGEN_IMAGE_MAPPINGS, **IMAGEN_EDIT_MAPPINGS, **VEO_MAPPINGS, **GEMINI_SEGMENT_MAPPINGS, **NANO_BANANA_MAPPINGS, **GEMINI_TTS_MAPPINGS} +NODE_DISPLAY_NAME_MAPPINGS = {**PRO_DISPLAY, **MAX_DISPLAY, **GEMINI_DISPLAY, **GPT_IMAGE_DISPLAY, **IMAGEN_IMAGE_DISPLAY, **IMAGEN_EDIT_DISPLAY, **VEO_DISPLAY, **GEMINI_SEGMENT_DISPLAY, **NANO_BANANA_DISPLAY, **GEMINI_TTS_DISPLAY} __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/gemini_tts.py b/gemini_tts.py new file mode 100644 index 0000000..05c08d9 --- /dev/null +++ b/gemini_tts.py @@ -0,0 +1,106 @@ +import os +import struct +import io +import torchaudio +from google.genai import Client, types + +class GeminiTTSNode: + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": ("STRING", {"multiline": True, "default": ""}), + "api_key": ("STRING", {"multiline": False, "default": ""}), + "model": ("STRING", {"default": "gemini-2.5-flash-preview-tts", "multiline": False}), + "voice_id": (["Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", "Callirrhoe", "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", "Despina", "Erinome", "Achernar", "Laomedeia", "Rasalgethi", "Algenib", "Achird", "Pulcherrima", "Gacrux", "Schedar", "Alnilam", "Sulafat", "Sadaltager", "Sadachbia", "Vindemiatrix", "Zubenelgenubi"],), + "seed": ("INT", {"default": 69, "min": -1, "max": 2147483646, "step": 1}), + "temperature": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { + "system_prompt": ("STRING", {"multiline": True, "default": ""}), + } + } + + RETURN_TYPES = ("AUDIO",) + RETURN_NAMES = ("audio",) + FUNCTION = "generate_speech" + CATEGORY = "audio/generation" + + def generate_speech(self, text, api_key, voice_id, temperature, model, seed, system_prompt=""): + + if not text.strip(): + raise ValueError("Text input cannot be empty.") + + key = api_key.strip() or os.environ.get("GEMINI_TTS_API_KEY") + if not key: + raise ValueError("No API key provided.") + + client = Client( + api_key=key, + http_options=types.HttpOptions( + retry_options=types.HttpRetryOptions(attempts=10, jitter=10) + ) + ) + + # Build prompt + prompt_text = text + if system_prompt.strip(): + prompt_text = system_prompt.strip() + ":\n\n\"" + text + "\"" + + contents = [types.Content(role="user", parts=[types.Part.from_text(text=prompt_text)])] + + config = types.GenerateContentConfig( + temperature=temperature, + seed=seed, + response_modalities=["audio"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) + ) + ), + ) + + # Generate audio + audio_data = b"" + mime_type = None + + for chunk in client.models.generate_content_stream( + model=model, + contents=contents, + config=config + ): + if (chunk.candidates and chunk.candidates[0].content and + chunk.candidates[0].content.parts and + chunk.candidates[0].content.parts[0].inline_data): + + inline_data = chunk.candidates[0].content.parts[0].inline_data + audio_data += inline_data.data + if mime_type is None: + mime_type = inline_data.mime_type + + if not audio_data: + raise ValueError("No audio data received from API.") + + if mime_type and "L16" in mime_type: + header = struct.pack( + "<4sI4s4sIHHIIHH4sI", + b"RIFF", 36 + len(audio_data), + b"WAVE", b"fmt ", 16, 1, 1, 24000, 48000, 2, 16, + b"data", len(audio_data) + ) + audio_data = header + audio_data + + # Decode audio from memory + audio_buffer = io.BytesIO(audio_data) + waveform, sample_rate = torchaudio.load(audio_buffer) + + # Return in ComfyUI audio format + return ({"waveform": waveform.unsqueeze(0), "sample_rate": sample_rate},) + + @classmethod + def IS_CHANGED(cls, **kwargs): + return f"{kwargs.get('text', '')}-{kwargs.get('voice_id', '')}-{kwargs.get('temperature', 1.0)}-{kwargs.get('model', '')}-{kwargs.get('seed', 69)}-{kwargs.get('system_prompt', '')}" + +NODE_CLASS_MAPPINGS = {"GeminiTTSNode": GeminiTTSNode} +NODE_DISPLAY_NAME_MAPPINGS = {"GeminiTTSNode": "Gemini TTS"} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 710dad3..5101b28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ pillow numpy torch google-genai -opencv-python \ No newline at end of file +opencv-python +torchaudio \ No newline at end of file