{"repo":"NousResearch/hermes-agent","public_id":"10918","display_label":"Add Google Gemini TTS provider feature","summary":"These items all concern adding Google Gemini as a text-to-speech provider. The members appear to be duplicate or closely related proposals/contributions toward the same feature.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11229,"merged_at":"2026-04-16T21:23:17+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-16T21:23:17+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":10918,"pr_number":11229,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-16T21:23:17+00:00"}],"members":[{"kind":"issue","number":10918,"title":"Feature: Add Google Gemini TTS as a speech-generation provider","url":null,"state":"closed"},{"kind":"pr","number":10922,"title":"feat(tts): add Gemini TTS provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11091","number":11091}],"category":"merged_via","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":11091,"title":"feat(tts): add Gemini TTS provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#10922","number":10922},{"kind":"pr","value":"#10918","number":10918},{"kind":"pr","value":"#11229","number":11229}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":11229,"title":"feat(tts): add Google Gemini TTS provider","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/setup.py","hermes_cli/tools_config.py","tests/tools/test_tts_gemini.py","tools/tts_tool.py","website/docs/user-guide/features/tts.md"],"fix_hunks":[{"file":"hermes_cli/setup.py","hunks":["@@ -430,6 +430,8 @@ def _print_setup_summary(config: dict, hermes_home):\n         tool_status.append((\"Text-to-Speech (MiniMax)\", True, None))\n     elif tts_provider == \"mistral\" and get_env_value(\"MISTRAL_API_KEY\"):\n         tool_status.append((\"Text-to-Speech (Mistral Voxtral)\", True, None))\n+    elif tts_provider == \"gemini\" and (get_env_value(\"GEMINI_API_KEY\") or get_env_value(\"GOOGLE_API_KEY\")):\n+        tool_status.append((\"Text-to-Speech (Google Gemini)\", True, None))\n     elif tts_provider == \"neutts\":\n         try:\n             import importlib.util","@@ -913,6 +915,7 @@ def _setup_tts_provider(config: dict):\n         \"xai\": \"xAI TTS\",\n         \"minimax\": \"MiniMax TTS\",\n         \"mistral\": \"Mistral Voxtral TTS\",\n+        \"gemini\": \"Google Gemini TTS\",\n         \"neutts\": \"NeuTTS\",\n     }\n     current_label = provider_labels.get(current_provider, current_provider)","@@ -935,10 +938,11 @@ def _setup_tts_provider(config: dict):\n             \"xAI TTS (Grok voices, needs API key)\",\n             \"MiniMax TTS (high quality with voice cloning, needs API key)\",\n             \"Mistral Voxtral TTS (multilingual, native Opus, needs API key)\",\n+            \"Google Gemini TTS (30 prebuilt voices, prompt-controllable, needs API key)\",\n             \"NeuTTS (local on-device, free, ~300MB model download)\",\n         ]\n     )\n-    providers.extend([\"edge\", \"elevenlabs\", \"openai\", \"xai\", \"minimax\", \"mistral\", \"neutts\"])\n+    providers.extend([\"edge\", \"elevenlabs\", \"openai\", \"xai\", \"minimax\", \"mistral\", \"gemini\", \"neutts\"])\n     choices.append(f\"Keep current ({current_label})\")\n     keep_current_idx = len(choices) - 1\n     idx = prompt_choice(\"Select TTS provider:\", choices, keep_current_idx)","@@ -1045,6 +1049,19 @@ def _setup_tts_provider(config: dict):\n                 print_warning(\"No API key provided. Falling back to Edge TTS.\")\n                 selected = \"edge\"\n \n+    elif selected == \"gemini\":\n+        existing = get_env_value(\"GEMINI_API_KEY\") or get_env_value(\"GOOGLE_API_KEY\")\n+        if not existing:\n+            print()\n+            print_info(\"Get a free API key at https://aistudio.google.com/app/apikey\")\n+            api_key = [redacted:secret-assignment]\"Gemini API key for TTS\", password=True)\n+            if api_key:\n+                save_env_value(\"GEMINI_API_KEY\", api_key)\n+                print_success(\"Gemini TTS API key saved\")\n+            else:\n+                print_warning(\"No API key provided. Falling back to Edge TTS.\")\n+                selected = \"edge\"\n+\n     # Save the selection\n     if \"tts\" not in config:\n         config[\"tts\"] = {}"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -172,6 +172,15 @@ def _get_plugin_toolset_keys() -> set:\n                 ],\n                 \"tts_provider\": \"mistral\",\n             },\n+            {\n+                \"name\": \"Google Gemini TTS\",\n+                \"badge\": \"preview\",\n+                \"tag\": \"30 prebuilt voices, controllable via prompts\",\n+                \"env_vars\": [\n+                    {\"key\": \"GEMINI_API_KEY\", \"prompt\": \"Gemini API key\", \"url\": \"https://aistudio.google.com/app/apikey\"},\n+                ],\n+                \"tts_provider\": \"gemini\",\n+            },\n         ],\n     },\n     \"web\": {"]},{"file":"tests/tools/test_tts_gemini.py","hunks":["@@ -0,0 +1,287 @@\n+\"\"\"Tests for the Google Gemini TTS provider in tools/tts_tool.py.\"\"\"\n+\n+import base64\n+import struct\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+\n[redacted:email](autouse=True)\n+def clean_env(monkeypatch):\n+    for key in (\n+        \"GEMINI_API_KEY\",\n+        \"GOOGLE_API_KEY\",\n+        \"GEMINI_BASE_URL\",\n+        \"HERMES_SESSION_PLATFORM\",\n+    ):\n+        monkeypatch.delenv(key, raising=False)\n+\n+\n[redacted:email]\n+def fake_pcm_bytes():\n+    # 0.1s of silence at 24kHz mono 16-bit = 4800 bytes\n+    return b\"\\x00\" * 4800\n+\n+\n[redacted:email]\n+def mock_gemini_response(fake_pcm_bytes):\n+    \"\"\"A successful Gemini generateContent response.\"\"\"\n+    resp = MagicMock()\n+    resp.status_code = 200\n+    resp.json.return_value = {\n+        \"candidates\": [\n+            {\n+                \"content\": {\n+                    \"parts\": [\n+                        {\n+                            \"inlineData\": {\n+                                \"mimeType\": \"audio/L16;codec=pcm;rate=24000\",\n+                                \"data\": base64.b64encode(fake_pcm_bytes).decode(),\n+                            }\n+                        }\n+                    ]\n+                }\n+            }\n+        ]\n+    }\n+    return resp\n+\n+\n+class TestWrapPcmAsWav:\n+    def test_riff_header_structure(self):\n+        from tools.tts_tool import _wrap_pcm_as_wav\n+\n+        pcm = b\"\\x01\\x02\\x03\\x04\" * 10\n+        wav = _wrap_pcm_as_wav(pcm, sample_rate=24000, channels=1, sample_width=2)\n+\n+        assert wav[:4] == b\"RIFF\"\n+        assert wav[8:12] == b\"WAVE\"\n+        assert wav[12:16] == b\"fmt \"\n+        # Audio format (PCM=1)\n+        assert struct.unpack(\"<H\", wav[20:22])[0] == 1\n+        # Channels\n+        assert struct.unpack(\"<H\", wav[22:24])[0] == 1\n+        # Sample rate\n+        assert struct.unpack(\"<I\", wav[24:28])[0] == 24000\n+        # Bits per sample\n+        assert struct.unpack(\"<H\", wav[34:36])[0] == 16\n+        assert wav[36:40] == b\"data\"\n+        assert wav[44:] == pcm\n+\n+    def test_header_size_is_44(self):\n+        from tools.tts_tool import _wrap_pcm_as_wav\n+\n+        pcm = b\"\\xff\" * 100\n+        wav = _wrap_pcm_as_wav(pcm)\n+        assert len(wav) == 44 + len(pcm)\n+\n+\n+class TestGenerateGeminiTts:\n+    def test_missing_api_key_raises_value_error(self, tmp_path):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        output_path = str(tmp_path / \"test.wav\")\n+        with pytest.raises(ValueError, match=\"GEMINI_API_KEY\"):\n+            _generate_gemini_tts(\"Hello\", output_path, {})\n+\n+    def test_google_api_key_fallback(self, tmp_path, monkeypatch, mock_gemini_response):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GOOGLE_API_KEY\", \"from-google-env\")\n+        output_path = str(tmp_path / \"test.wav\")\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response) as mock_post:\n+            _generate_gemini_tts(\"Hi\", output_path, {})\n+\n+        # Confirm it used the GOOGLE_API_KEY as the query parameter\n+        _, kwargs = mock_post.call_args\n+        assert kwargs[\"params\"][\"key\"] == \"from-google-env\"\n+\n+    def test_wav_output_fast_path(self, tmp_path, monkeypatch, mock_gemini_response, fake_pcm_bytes):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        output_path = str(tmp_path / \"test.wav\")\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response):\n+            result = _generate_gemini_tts(\"Hi\", output_path, {})\n+\n+        assert result == output_path\n+        data = (tmp_path / \"test.wav\").read_bytes()\n+        assert data[:4] == b\"RIFF\"\n+        assert data[8:12] == b\"WAVE\"\n+        # Audio payload should match the PCM we put in\n+        assert data[44:] == fake_pcm_bytes\n+\n+    def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response):\n+        from tools.tts_tool import (\n+            DEFAULT_GEMINI_TTS_MODEL,\n+            DEFAULT_GEMINI_TTS_VOICE,\n+            _generate_gemini_tts,\n+        )\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response) as mock_post:\n+            _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), {})\n+\n+        args, kwargs = mock_post.call_args\n+        assert DEFAULT_GEMINI_TTS_MODEL in args[0]\n+        payload = kwargs[\"json\"]\n+        voice = (\n+            payload[\"generationConfig\"][\"speechConfig\"][\"voiceConfig\"]\n+            [\"prebuiltVoiceConfig\"][\"voiceName\"]\n+        )\n+        assert voice == DEFAULT_GEMINI_TTS_VOICE\n+\n+    def test_custom_voice(self, tmp_path, monkeypatch, mock_gemini_response):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        config = {\"gemini\": {\"voice\": \"Puck\"}}\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response) as mock_post:\n+            _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), config)\n+\n+        payload = mock_post.call_args[1][\"json\"]\n+        voice = (\n+            payload[\"generationConfig\"][\"speechConfig\"][\"voiceConfig\"]\n+            [\"prebuiltVoiceConfig\"][\"voiceName\"]\n+        )\n+        assert voice == \"Puck\"\n+\n+    def test_custom_model(self, tmp_path, monkeypatch, mock_gemini_response):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        config = {\"gemini\": {\"model\": \"gemini-2.5-pro-preview-tts\"}}\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response) as mock_post:\n+            _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), config)\n+\n+        endpoint = mock_post.call_args[0][0]\n+        assert \"gemini-2.5-pro-preview-tts\" in endpoint\n+\n+    def test_response_modality_is_audio(self, tmp_path, monkeypatch, mock_gemini_response):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response) as mock_post:\n+            _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), {})\n+\n+        payload = mock_post.call_args[1][\"json\"]\n+        assert payload[\"generationConfig\"][\"responseModalities\"] == [\"AUDIO\"]\n+\n+    def test_http_error_raises_runtime_error(self, tmp_path, monkeypatch):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        err_resp = MagicMock()\n+        err_resp.status_code = 400\n+        err_resp.json.return_value = {\"error\": {\"message\": \"Invalid voice\"}}\n+\n+        with patch(\"requests.post\", return_value=err_resp):\n+            with pytest.raises(RuntimeError, match=\"HTTP 400.*Invalid voice\"):\n+                _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), {})\n+\n+    def test_empty_audio_raises(self, tmp_path, monkeypatch):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        resp = MagicMock()\n+        resp.status_code = 200\n+        resp.json.return_value = {\n+            \"candidates\": [\n+                {\"content\": {\"parts\": [{\"inlineData\": {\"data\": \"\"}}]}}\n+            ]\n+        }\n+\n+        with patch(\"requests.post\", return_value=resp):\n+            with pytest.raises(RuntimeError, match=\"empty audio\"):\n+                _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), {})\n+\n+    def test_malformed_response_raises(self, tmp_path, monkeypatch):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        resp = MagicMock()\n+        resp.status_code = 200\n+        resp.json.return_value = {\"candidates\": []}  # no content\n+\n+        with patch(\"requests.post\", return_value=resp):\n+            with pytest.raises(RuntimeError, match=\"malformed\"):\n+                _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), {})\n+\n+    def test_snake_case_inline_data_accepted(self, tmp_path, monkeypatch, fake_pcm_bytes):\n+        \"\"\"Some Gemini SDK versions return inline_data instead of inlineData.\"\"\"\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        resp = MagicMock()\n+        resp.status_code = 200\n+        resp.json.return_value = {\n+            \"candidates\": [\n+                {\n+                    \"content\": {\n+                        \"parts\": [\n+                            {\n+                                \"inline_data\": {\n+                                    \"data\": base64.b64encode(fake_pcm_bytes).decode()\n+                                }\n+                            }\n+                        ]\n+                    }\n+                }\n+            ]\n+        }\n+\n+        output_path = str(tmp_path / \"test.wav\")\n+        with patch(\"requests.post\", return_value=resp):\n+            _generate_gemini_tts(\"Hi\", output_path, {})\n+\n+        data = (tmp_path / \"test.wav\").read_bytes()\n+        assert data[:4] == b\"RIFF\"\n+\n+    def test_custom_base_url_env(self, tmp_path, monkeypatch, mock_gemini_response):\n+        from tools.tts_tool import _generate_gemini_tts\n+\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"test-key\")\n+        monkeypatch.setenv(\"GEMINI_BASE_URL\", \"https://custom-gemini.example.com/v1beta\")\n+\n+        with patch(\"requests.post\", return_value=mock_gemini_response) as mock_post:\n+            _generate_gemini_tts(\"Hi\", str(tmp_path / \"test.wav\"), {})\n+\n+        assert mock_post.call_args[0][0].startswith(\"https://custom-gemini.example.com/v1beta/\")\n+\n+\n+class TestGeminiInCheckRequirements:\n+    def test_gemini_api_key_satisfies_requirements(self, monkeypatch):\n+        from tools.tts_tool import check_tts_requirements\n+\n+        # Strip everything else\n+        for key in (\n+            \"ELEVENLABS_API_KEY\",\n+            \"OPENAI_API_KEY\",\n+            \"VOICE_TOOLS_OPENAI_KEY\",\n+            \"MINIMAX_API_KEY\",\n+            \"XAI_API_KEY\",\n+            \"MISTRAL_API_KEY\",\n+            \"GOOGLE_API_KEY\",\n+        ):\n+            monkeypatch.delenv(key, raising=False)\n+        monkeypatch.setenv(\"GEMINI_API_KEY\", \"k\")\n+\n+        # Force edge_tts import to fail so we actually hit the gemini check\n+        import builtins\n+\n+        real_import = builtins.__import__\n+\n+        def fake_import(name, *args, **kwargs):\n+            if name == \"edge_tts\":\n+                raise ImportError(\"simulated\")\n+            return real_import(name, *args, **kwargs)\n+\n+        with patch(\"builtins.__import__\", side_effect=fake_import):\n+            assert check_tts_requirements() is True"]},{"file":"tools/tts_tool.py","hunks":["@@ -2,12 +2,13 @@\n \"\"\"\n Text-to-Speech Tool Module\n \n-Supports six TTS providers:\n+Supports seven TTS providers:\n - Edge TTS (default, free, no API key): Microsoft Edge neural voices\n - ElevenLabs (premium): High-quality voices, needs ELEVENLABS_API_KEY\n - OpenAI TTS: Good quality, needs OPENAI_API_KEY\n - MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY\n - Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY\n+- Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY\n - NeuTTS (local, free, no API key): On-device TTS via neutts_cli, needs neutts installed\n \n Output formats:","@@ -99,6 +100,13 @@ def _import_sounddevice():\n DEFAULT_XAI_SAMPLE_RATE = 24000\n DEFAULT_XAI_BIT_RATE = 128000\n DEFAULT_XAI_BASE_URL = \"https://api.x.ai/v1\"\n+DEFAULT_GEMINI_TTS_MODEL = \"gemini-2.5-flash-preview-tts\"\n+DEFAULT_GEMINI_TTS_VOICE = \"Kore\"\n+DEFAULT_GEMINI_TTS_BASE_URL = \"https://generativelanguage.googleapis.com/v1beta\"\n+# PCM output specs for Gemini TTS (fixed by the API)\n+GEMINI_TTS_SAMPLE_RATE = 24000\n+GEMINI_TTS_CHANNELS = 1\n+GEMINI_TTS_SAMPLE_WIDTH = 2  # 16-bit PCM (L16)\n \n def _get_default_output_dir() -> str:\n     from hermes_constants import get_hermes_dir","@@ -506,6 +514,174 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any\n     return output_path\n \n \n+# ===========================================================================\n+# Provider: Google Gemini TTS\n+# ===========================================================================\n+def _wrap_pcm_as_wav(\n+    pcm_bytes: bytes,\n+    sample_rate: int = GEMINI_TTS_SAMPLE_RATE,\n+    channels: int = GEMINI_TTS_CHANNELS,\n+    sample_width: int = GEMINI_TTS_SAMPLE_WIDTH,\n+) -> bytes:\n+    \"\"\"Wrap raw signed-little-endian PCM with a standard WAV RIFF header.\n+\n+    Gemini TTS returns audio/L16;codec=pcm;rate=24000 -- raw PCM samples with\n+    no container. We add a minimal WAV header so the file is playable and\n+    ffmpeg can re-encode it to MP3/Opus downstream.\n+    \"\"\"\n+    import struct\n+\n+    byte_rate = sample_rate * channels * sample_width\n+    block_align = channels * sample_width\n+    data_size = len(pcm_bytes)\n+    fmt_chunk = struct.pack(\n+        \"<4sIHHIIHH\",\n+        b\"fmt \",\n+        16,             # fmt chunk size (PCM)\n+        1,              # audio format (PCM)\n+        channels,\n+        sample_rate,\n+        byte_rate,\n+        block_align,\n+        sample_width * 8,\n+    )\n+    data_chunk_header = struct.pack(\"<4sI\", b\"data\", data_size)\n+    riff_size = 4 + len(fmt_chunk) + len(data_chunk_header) + data_size\n+    riff_header = struct.pack(\"<4sI4s\", b\"RIFF\", riff_size, b\"WAVE\")\n+    return riff_header + fmt_chunk + data_chunk_header + pcm_bytes\n+\n+\n+def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:\n+    \"\"\"Generate audio using Google Gemini TTS.\n+\n+    Gemini's generateContent endpoint with responseModalities=[\"AUDIO\"] returns\n+    raw 24kHz mono 16-bit PCM (L16) as base64. We wrap it with a WAV RIFF\n+    header to produce a playable file, then ffmpeg-convert to MP3 / Opus if\n+    the caller requested those formats (same pattern as NeuTTS).\n+\n+    Args:\n+        text: Text to convert (prompt-style; supports inline direction like\n+              \"Say cheerfully:\" and audio tags like [whispers]).\n+        output_path: Where to save the audio file (.wav, .mp3, or .ogg).\n+        tts_config: TTS config dict.\n+\n+    Returns:\n+        Path to the saved audio file.\n+    \"\"\"\n+    import requests\n+\n+    api_key = [redacted:secret-assignment]\"GEMINI_API_KEY\") or os.getenv(\"GOOGLE_API_KEY\") or \"\").strip()\n+    if not api_key:\n+        raise ValueError(\n+            \"GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey\"\n+        )\n+\n+    gemini_config = tts_config.get(\"gemini\", {})\n+    model = str(gemini_config.get(\"model\", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL\n+    voice = str(gemini_config.get(\"voice\", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE\n+    base_url = str(\n+        gemini_config.get(\"base_url\")\n+        or os.getenv(\"GEMINI_BASE_URL\")\n+        or DEFAULT_GEMINI_TTS_BASE_URL\n+    ).strip().rstrip(\"/\")\n+\n+    payload: Dict[str, Any] = {\n+        \"contents\": [{\"parts\": [{\"text\": text}]}],\n+        \"generationConfig\": {\n+            \"responseModalities\": [\"AUDIO\"],\n+            \"speechConfig\": {\n+                \"voiceConfig\": {\n+                    \"prebuiltVoiceConfig\": {\"voiceName\": voice},\n+                },\n+            },\n+        },\n+    }\n+\n+    endpoint = f\"{base_url}/models/{model}:generateContent\"\n+    response = requests.post(\n+        endpoint,\n+        params={\"key\": api_key},\n+        headers={\"Content-Type\": \"application/json\"},\n+        json=payload,\n+        timeout=60,\n+    )\n+    if response.status_code != 200:\n+        # Surface the API error message when present\n+        try:\n+            err = response.json().get(\"error\", {})\n+            detail = err.get(\"message\") or response.text[:300]\n+        except Exception:\n+            detail = response.text[:300]\n+        raise RuntimeError(\n+            f\"Gemini TTS API error (HTTP {response.status_code}): {detail}\"\n+        )\n+\n+    try:\n+        data = response.json()\n+        parts = data[\"candidates\"][0][\"content\"][\"parts\"]\n+        audio_part = next((p for p in parts if \"inlineData\" in p or \"inline_data\" in p), None)\n+        if audio_part is None:\n+            raise RuntimeError(\"Gemini TTS response contained no audio data\")\n+        inline = audio_part.get(\"inlineData\") or audio_part.get(\"inline_data\") or {}\n+        audio_b64 = inline.get(\"data\", \"\")\n+    except (KeyError, IndexError, TypeError) as e:\n+        raise RuntimeError(f\"Gemini TTS response was malformed: {e}\") from e\n+\n+    if not audio_b64:\n+        raise RuntimeError(\"Gemini TTS returned empty audio data\")\n+\n+    pcm_bytes = base64.b64decode(audio_b64)\n+    wav_bytes = _wrap_pcm_as_wav(pcm_bytes)\n+\n+    # Fast path: caller wants WAV directly, just write.\n+    if output_path.lower().endswith(\".wav\"):\n+        with open(output_path, \"wb\") as f:\n+            f.write(wav_bytes)\n+        return output_path\n+\n+    # Otherwise write WAV to a temp file and ffmpeg-convert to the target\n+    # format (.mp3 or .ogg). If ffmpeg is missing, fall back to renaming the\n+    # WAV -- this matches the NeuTTS behavior and keeps the tool usable on\n+    # systems without ffmpeg (audio still plays, just with a misleading\n+    # extension).\n+    with tempfile.NamedTemporaryFile(suffix=\".wav\", delete=False) as tmp:\n+        tmp.write(wav_bytes)\n+        wav_path = tmp.name\n+\n+    try:\n+        ffmpeg = shutil.which(\"ffmpeg\")\n+        if ffmpeg:\n+            # For .ogg output, force libopus encoding (Telegram voice bubbles\n+            # require Opus specifically; ffmpeg's default for .ogg is Vorbis).\n+            if output_path.lower().endswith(\".ogg\"):\n+                cmd = [\n+                    ffmpeg, \"-i\", wav_path,\n+                    \"-acodec\", \"libopus\", \"-ac\", \"1\",\n+                    \"-b:a\", \"64k\", \"-vbr\", \"off\",\n+                    \"-y\", \"-loglevel\", \"error\",\n+                    output_path,\n+                ]\n+            else:\n+                cmd = [ffmpeg, \"-i\", wav_path, \"-y\", \"-loglevel\", \"error\", output_path]\n+            result = subprocess.run(cmd, capture_output=True, timeout=30)\n+            if result.returncode != 0:\n+                stderr = result.stderr.decode(\"utf-8\", errors=\"ignore\")[:300]\n+                raise RuntimeError(f\"ffmpeg conversion failed: {stderr}\")\n+        else:\n+            logger.warning(\n+                \"ffmpeg not found; writing raw WAV to %s (extension may be misleading)\",\n+                output_path,\n+            )\n+            shutil.copyfile(wav_path, output_path)\n+    finally:\n+        try:\n+            os.remove(wav_path)\n+        except OSError:\n+            pass\n+\n+    return output_path\n+\n+\n # ===========================================================================\n # NeuTTS (local, on-device TTS via neutts_cli)\n # ===========================================================================","@@ -634,7 +810,7 @@ def text_to_speech_tool(\n         out_dir.mkdir(parents=True, exist_ok=True)\n         # Use .ogg for Telegram with providers that support native Opus output,\n         # otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later).\n-        if want_opus and provider in (\"openai\", \"elevenlabs\", \"mistral\"):\n+        if want_opus and provider in (\"openai\", \"elevenlabs\", \"mistral\", \"gemini\"):\n             file_path = out_dir / f\"tts_{timestamp}.ogg\"\n         else:\n             file_path = out_dir / f\"tts_{timestamp}.mp3\"","@@ -687,6 +863,10 @@ def text_to_speech_tool(\n             logger.info(\"Generating speech with Mistral Voxtral TTS...\")\n             _generate_mistral_tts(text, file_str, tts_config)\n \n+        elif provider == \"gemini\":\n+            logger.info(\"Generating speech with Google Gemini TTS...\")\n+            _generate_gemini_tts(text, file_str, tts_config)\n+\n         elif provider == \"neutts\":\n             if not _check_neutts_available():\n                 return json.dumps({","@@ -741,7 +921,7 @@ def text_to_speech_tool(\n             if opus_path:\n                 file_str = opus_path\n                 voice_compatible = True\n-        elif provider in (\"elevenlabs\", \"openai\", \"mistral\"):\n+        elif provider in (\"elevenlabs\", \"openai\", \"mistral\", \"gemini\"):\n             voice_compatible = file_str.endswith(\".ogg\")\n \n         file_size = os.path.getsize(file_str)","@@ -811,6 +991,8 @@ def check_tts_requirements() -> bool:\n         return True\n     if os.getenv(\"XAI_API_KEY\"):\n         return True\n+    if os.getenv(\"GEMINI_API_KEY\") or os.getenv(\"GOOGLE_API_KEY\"):\n+        return True\n     try:\n         _import_mistral_client()\n         if os.getenv(\"MISTRAL_API_KEY\"):"]},{"file":"website/docs/user-guide/features/tts.md","hunks":["@@ -14,7 +14,7 @@ If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription,\n \n ## Text-to-Speech\n \n-Convert text to speech with six providers:\n+Convert text to speech with seven providers:\n \n | Provider | Quality | Cost | API Key |\n |----------|---------|------|---------|","@@ -23,6 +23,7 @@ Convert text to speech with six providers:\n | **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |\n | **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` |\n | **Mistral (Voxtral TTS)** | Excellent | Paid | `MISTRAL_API_KEY` |\n+| **Google Gemini TTS** | Excellent | Free tier | `GEMINI_API_KEY` |\n | **NeuTTS** | Good | Free | None needed |\n \n ### Platform Delivery","@@ -39,7 +40,7 @@ Convert text to speech with six providers:\n ```yaml\n # In ~/.hermes/config.yaml\n tts:\n-  provider: \"edge\"              # \"edge\" | \"elevenlabs\" | \"openai\" | \"minimax\" | \"mistral\" | \"neutts\"\n+  provider: \"edge\"              # \"edge\" | \"elevenlabs\" | \"openai\" | \"minimax\" | \"mistral\" | \"gemini\" | \"neutts\"\n   speed: 1.0                    # Global speed multiplier (provider-specific settings override this)\n   edge:\n     voice: \"en-US-AriaNeural\"   # 322 voices, 74 languages","@@ -61,6 +62,9 @@ tts:\n   mistral:\n     model: \"voxtral-mini-tts-2603\"\n     voice_id: \"c69964a6-ab8b-4f8a-9465-ec0925096ec8\"  # Paul - Neutral (default)\n+  gemini:\n+    model: \"gemini-2.5-flash-preview-tts\"  # or gemini-2.5-pro-preview-tts\n+    voice: \"Kore\"               # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, Gacrux, etc.\n   neutts:\n     ref_audio: ''\n     ref_text: ''","@@ -77,6 +81,7 @@ Telegram voice bubbles require Opus/OGG audio format:\n - **OpenAI, ElevenLabs, and Mistral** produce Opus natively — no extra setup\n - **Edge TTS** (default) outputs MP3 and needs **ffmpeg** to convert:\n - **MiniMax TTS** outputs MP3 and needs **ffmpeg** to convert for Telegram voice bubbles\n+- **Google Gemini TTS** outputs raw PCM and uses **ffmpeg** to encode Opus directly for Telegram voice bubbles\n - **NeuTTS** outputs WAV and also needs **ffmpeg** to convert for Telegram voice bubbles\n \n ```bash\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}