# NousResearch/hermes-agent · Add Google Gemini TTS provider feature

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #11229. Verify it addresses your case.
- **Recommended:** read PR #11229 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #10918: Feature: Add Google Gemini TTS as a speech-generation provider

## Prior & competing PRs

- **#10922: feat(tts): add Gemini TTS provider**. closed without merge · fix tier `suggested`
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#11091`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#11229: feat(tts): add Google Gemini TTS provider**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `hermes_cli/setup.py`

    ```diff
    @@ -430,6 +430,8 @@ def _print_setup_summary(config: dict, hermes_home):
             tool_status.append(("Text-to-Speech (MiniMax)", True, None))
         elif tts_provider == "mistral" and get_env_value("MISTRAL_API_KEY"):
             tool_status.append(("Text-to-Speech (Mistral Voxtral)", True, None))
    +    elif tts_provider == "gemini" and (get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")):
    +        tool_status.append(("Text-to-Speech (Google Gemini)", True, None))
         elif tts_provider == "neutts":
             try:
                 import importlib.util
    ```

    ```diff
    @@ -913,6 +915,7 @@ def _setup_tts_provider(config: dict):
             "xai": "xAI TTS",
             "minimax": "MiniMax TTS",
             "mistral": "Mistral Voxtral TTS",
    +        "gemini": "Google Gemini TTS",
             "neutts": "NeuTTS",
         }
         current_label = provider_labels.get(current_provider, current_provider)
    ```

    ```diff
    @@ -935,10 +938,11 @@ def _setup_tts_provider(config: dict):
                 "xAI TTS (Grok voices, needs API key)",
                 "MiniMax TTS (high quality with voice cloning, needs API key)",
                 "Mistral Voxtral TTS (multilingual, native Opus, needs API key)",
    +            "Google Gemini TTS (30 prebuilt voices, prompt-controllable, needs API key)",
                 "NeuTTS (local on-device, free, ~300MB model download)",
             ]
         )
    -    providers.extend(["edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "neutts"])
    +    providers.extend(["edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "gemini", "neutts"])
         choices.append(f"Keep current ({current_label})")
         keep_current_idx = len(choices) - 1
         idx = prompt_choice("Select TTS provider:", choices, keep_current_idx)
    ```

    ```diff
    @@ -1045,6 +1049,19 @@ def _setup_tts_provider(config: dict):
                     print_warning("No API key provided. Falling back to Edge TTS.")
                     selected = "edge"
     
    +    elif selected == "gemini":
    +        existing = get_env_value("GEMINI_API_KEY") or get_env_value("GOOGLE_API_KEY")
    +        if not existing:
    +            print()
    +            print_info("Get a free API key at https://aistudio.google.com/app/apikey")
    +            api_key = [redacted:secret-assignment]"Gemini API key for TTS", password=True)
    +            if api_key:
    +                save_env_value("GEMINI_API_KEY", api_key)
    +                print_success("Gemini TTS API key saved")
    +            else:
    +                print_warning("No API key provided. Falling back to Edge TTS.")
    +                selected = "edge"
    +
         # Save the selection
         if "tts" not in config:
             config["tts"] = {}
    ```
  - `hermes_cli/tools_config.py`

    ```diff
    @@ -172,6 +172,15 @@ def _get_plugin_toolset_keys() -> set:
                     ],
                     "tts_provider": "mistral",
                 },
    +            {
    +                "name": "Google Gemini TTS",
    +                "badge": "preview",
    +                "tag": "30 prebuilt voices, controllable via prompts",
    +                "env_vars": [
    +                    {"key": "GEMINI_API_KEY", "prompt": "Gemini API key", "url": "https://aistudio.google.com/app/apikey"},
    +                ],
    +                "tts_provider": "gemini",
    +            },
             ],
         },
         "web": {
    ```
  - `tests/tools/test_tts_gemini.py`

    ```diff
    @@ -0,0 +1,287 @@
    +"""Tests for the Google Gemini TTS provider in tools/tts_tool.py."""
    +
    +import base64
    +import struct
    +from unittest.mock import MagicMock, patch
    +
    +import pytest
    +
    +
    [redacted:email](autouse=True)
    +def clean_env(monkeypatch):
    +    for key in (
    +        "GEMINI_API_KEY",
    +        "GOOGLE_API_KEY",
    +        "GEMINI_BASE_URL",
    +        "HERMES_SESSION_PLATFORM",
    +    ):
    +        monkeypatch.delenv(key, raising=False)
    +
    +
    [redacted:email]
    +def fake_pcm_bytes():
    +    # 0.1s of silence at 24kHz mono 16-bit = 4800 bytes
    +    return b"\x00" * 4800
    +
    +
    [redacted:email]
    +def mock_gemini_response(fake_pcm_bytes):
    +    """A successful Gemini generateContent response."""
    +    resp = MagicMock()
    +    resp.status_code = 200
    +    resp.json.return_value = {
    +        "candidates": [
    +            {
    +                "content": {
    +                    "parts": [
    +                        {
    +                            "inlineData": {
    +                                "mimeType": "audio/L16;codec=pcm;rate=24000",
    +                                "data": base64.b64encode(fake_pcm_bytes).decode(),
    +                            }
    +                        }
    +                    ]
    +                }
    +            }
    +        ]
    +    }
    +    return resp
    +
    +
    +class TestWrapPcmAsWav:
    +    def test_riff_header_structure(self):
    +        from tools.tts_tool import _wrap_pcm_as_wav
    +
    +        pcm = b"\x01\x02\x03\x04" * 10
    +        wav = _wrap_pcm_as_wav(pcm, sample_rate=24000, channels=1, sample_width=2)
    +
    +        assert wav[:4] == b"RIFF"
    +        assert wav[8:12] == b"WAVE"
    +        assert wav[12:16] == b"fmt "
    +        # Audio format (PCM=1)
    +        assert struct.unpack("<H", wav[20:22])[0] == 1
    +        # Channels
    +        assert struct.unpack("<H", wav[22:24])[0] == 1
    +        # Sample rate
    +        assert struct.unpack("<I", wav[24:28])[0] == 24000
    +        # Bits per sample
    +        assert struct.unpack("<H", wav[34:36])[0] == 16
    +        assert wav[36:40] == b"data"
    +        assert wav[44:] == pcm
    +
    +    def test_header_size_is_44(self):
    +        from tools.tts_tool import _wrap_pcm_as_wav
    +
    +        pcm = b"\xff" * 100
    +        wav = _wrap_pcm_as_wav(pcm)
    +        assert len(wav) == 44 + len(pcm)
    +
    +
    +class TestGenerateGeminiTts:
    +    def test_missing_api_key_raises_value_error(self, tmp_path):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        output_path = str(tmp_path / "test.wav")
    +        with pytest.raises(ValueError, match="GEMINI_API_KEY"):
    +            _generate_gemini_tts("Hello", output_path, {})
    +
    +    def test_google_api_key_fallback(self, tmp_path, monkeypatch, mock_gemini_response):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GOOGLE_API_KEY", "from-google-env")
    +        output_path = str(tmp_path / "test.wav")
    +
    +        with patch("requests.post", return_value=mock_gemini_response) as mock_post:
    +            _generate_gemini_tts("Hi", output_path, {})
    +
    +        # Confirm it used the GOOGLE_API_KEY as the query parameter
    +        _, kwargs = mock_post.call_args
    +        assert kwargs["params"]["key"] == "from-google-env"
    +
    +    def test_wav_output_fast_path(self, tmp_path, monkeypatch, mock_gemini_response, fake_pcm_bytes):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        output_path = str(tmp_path / "test.wav")
    +
    +        with patch("requests.post", return_value=mock_gemini_response):
    +            result = _generate_gemini_tts("Hi", output_path, {})
    +
    +        assert result == output_path
    +        data = (tmp_path / "test.wav").read_bytes()
    +        assert data[:4] == b"RIFF"
    +        assert data[8:12] == b"WAVE"
    +        # Audio payload should match the PCM we put in
    +        assert data[44:] == fake_pcm_bytes
    +
    +    def test_default_voice_and_model(self, tmp_path, monkeypatch, mock_gemini_response):
    +        from tools.tts_tool import (
    +            DEFAULT_GEMINI_TTS_MODEL,
    +            DEFAULT_GEMINI_TTS_VOICE,
    +            _generate_gemini_tts,
    +        )
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +
    +        with patch("requests.post", return_value=mock_gemini_response) as mock_post:
    +            _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
    +
    +        args, kwargs = mock_post.call_args
    +        assert DEFAULT_GEMINI_TTS_MODEL in args[0]
    +        payload = kwargs["json"]
    +        voice = (
    +            payload["generationConfig"]["speechConfig"]["voiceConfig"]
    +            ["prebuiltVoiceConfig"]["voiceName"]
    +        )
    +        assert voice == DEFAULT_GEMINI_TTS_VOICE
    +
    +    def test_custom_voice(self, tmp_path, monkeypatch, mock_gemini_response):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        config = {"gemini": {"voice": "Puck"}}
    +
    +        with patch("requests.post", return_value=mock_gemini_response) as mock_post:
    +            _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
    +
    +        payload = mock_post.call_args[1]["json"]
    +        voice = (
    +            payload["generationConfig"]["speechConfig"]["voiceConfig"]
    +            ["prebuiltVoiceConfig"]["voiceName"]
    +        )
    +        assert voice == "Puck"
    +
    +    def test_custom_model(self, tmp_path, monkeypatch, mock_gemini_response):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        config = {"gemini": {"model": "gemini-2.5-pro-preview-tts"}}
    +
    +        with patch("requests.post", return_value=mock_gemini_response) as mock_post:
    +            _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
    +
    +        endpoint = mock_post.call_args[0][0]
    +        assert "gemini-2.5-pro-preview-tts" in endpoint
    +
    +    def test_response_modality_is_audio(self, tmp_path, monkeypatch, mock_gemini_response):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +
    +        with patch("requests.post", return_value=mock_gemini_response) as mock_post:
    +            _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
    +
    +        payload = mock_post.call_args[1]["json"]
    +        assert payload["generationConfig"]["responseModalities"] == ["AUDIO"]
    +
    +    def test_http_error_raises_runtime_error(self, tmp_path, monkeypatch):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        err_resp = MagicMock()
    +        err_resp.status_code = 400
    +        err_resp.json.return_value = {"error": {"message": "Invalid voice"}}
    +
    +        with patch("requests.post", return_value=err_resp):
    +            with pytest.raises(RuntimeError, match="HTTP 400.*Invalid voice"):
    +                _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
    +
    +    def test_empty_audio_raises(self, tmp_path, monkeypatch):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        resp = MagicMock()
    +        resp.status_code = 200
    +        resp.json.return_value = {
    +            "candidates": [
    +                {"content": {"parts": [{"inlineData": {"data": ""}}]}}
    +            ]
    +        }
    +
    +        with patch("requests.post", return_value=resp):
    +            with pytest.raises(RuntimeError, match="empty audio"):
    +                _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
    +
    +    def test_malformed_response_raises(self, tmp_path, monkeypatch):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        resp = MagicMock()
    +        resp.status_code = 200
    +        resp.json.return_value = {"candidates": []}  # no content
    +
    +        with patch("requests.post", return_value=resp):
    +            with pytest.raises(RuntimeError, match="malformed"):
    +                _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
    +
    +    def test_snake_case_inline_data_accepted(self, tmp_path, monkeypatch, fake_pcm_bytes):
    +        """Some Gemini SDK versions return inline_data instead of inlineData."""
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        resp = MagicMock()
    +        resp.status_code = 200
    +        resp.json.return_value = {
    +            "candidates": [
    +                {
    +                    "content": {
    +                        "parts": [
    +                            {
    +                                "inline_data": {
    +                                    "data": base64.b64encode(fake_pcm_bytes).decode()
    +                                }
    +                            }
    +                        ]
    +                    }
    +                }
    +            ]
    +        }
    +
    +        output_path = str(tmp_path / "test.wav")
    +        with patch("requests.post", return_value=resp):
    +            _generate_gemini_tts("Hi", output_path, {})
    +
    +        data = (tmp_path / "test.wav").read_bytes()
    +        assert data[:4] == b"RIFF"
    +
    +    def test_custom_base_url_env(self, tmp_path, monkeypatch, mock_gemini_response):
    +        from tools.tts_tool import _generate_gemini_tts
    +
    +        monkeypatch.setenv("GEMINI_API_KEY", "test-key")
    +        monkeypatch.setenv("GEMINI_BASE_URL", "https://custom-gemini.example.com/v1beta")
    +
    +        with patch("requests.post", return_value=mock_gemini_response) as mock_post:
    +            _generate_gemini_tts("Hi", str(tmp_path / "test.wav"), {})
    +
    +        assert mock_post.call_args[0][0].startswith("https://custom-gemini.example.com/v1beta/")
    +
    +
    +class TestGeminiInCheckRequirements:
    +    def test_gemini_api_key_satisfies_requirements(self, monkeypatch):
    +        from tools.tts_tool import check_tts_requirements
    +
    +        # Strip everything else
    +        for key in (
    +            "ELEVENLABS_API_KEY",
    +            "OPENAI_API_KEY",
    +            "VOICE_TOOLS_OPENAI_KEY",
    +            "MINIMAX_API_KEY",
    +            "XAI_API_KEY",
    +            "MISTRAL_API_KEY",
    +            "GOOGLE_API_KEY",
    +        ):
    +            monkeypatch.delenv(key, raising=False)
    +        monkeypatch.setenv("GEMINI_API_KEY", "k")
    +
    +        # Force edge_tts import to fail so we actually hit the gemini check
    +        import builtins
    +
    +        real_import = builtins.__import__
    +
    +        def fake_import(name, *args, **kwargs):
    +            if name == "edge_tts":
    +                raise ImportError("simulated")
    +            return real_import(name, *args, **kwargs)
    +
    +        with patch("builtins.__import__", side_effect=fake_import):
    +            assert check_tts_requirements() is True
    ```
  - `tools/tts_tool.py`

    ```diff
    @@ -2,12 +2,13 @@
     """
     Text-to-Speech Tool Module
     
    -Supports six TTS providers:
    +Supports seven TTS providers:
     - Edge TTS (default, free, no API key): Microsoft Edge neural voices
     - ElevenLabs (premium): High-quality voices, needs ELEVENLABS_API_KEY
     - OpenAI TTS: Good quality, needs OPENAI_API_KEY
     - MiniMax TTS: High-quality with voice cloning, needs MINIMAX_API_KEY
     - Mistral (Voxtral TTS): Multilingual, native Opus, needs MISTRAL_API_KEY
    +- Google Gemini TTS: Controllable, 30 prebuilt voices, needs GEMINI_API_KEY
     - NeuTTS (local, free, no API key): On-device TTS via neutts_cli, needs neutts installed
     
     Output formats:
    ```

    ```diff
    @@ -99,6 +100,13 @@ def _import_sounddevice():
     DEFAULT_XAI_SAMPLE_RATE = 24000
     DEFAULT_XAI_BIT_RATE = 128000
     DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
    +DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
    +DEFAULT_GEMINI_TTS_VOICE = "Kore"
    +DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
    +# PCM output specs for Gemini TTS (fixed by the API)
    +GEMINI_TTS_SAMPLE_RATE = 24000
    +GEMINI_TTS_CHANNELS = 1
    +GEMINI_TTS_SAMPLE_WIDTH = 2  # 16-bit PCM (L16)
     
     def _get_default_output_dir() -> str:
         from hermes_constants import get_hermes_dir
    ```

    ```diff
    @@ -506,6 +514,174 @@ def _generate_mistral_tts(text: str, output_path: str, tts_config: Dict[str, Any
         return output_path
     
     
    +# ===========================================================================
    +# Provider: Google Gemini TTS
    +# ===========================================================================
    +def _wrap_pcm_as_wav(
    +    pcm_bytes: bytes,
    +    sample_rate: int = GEMINI_TTS_SAMPLE_RATE,
    +    channels: int = GEMINI_TTS_CHANNELS,
    +    sample_width: int = GEMINI_TTS_SAMPLE_WIDTH,
    +) -> bytes:
    +    """Wrap raw signed-little-endian PCM with a standard WAV RIFF header.
    +
    +    Gemini TTS returns audio/L16;codec=pcm;rate=24000 -- raw PCM samples with
    +    no container. We add a minimal WAV header so the file is playable and
    +    ffmpeg can re-encode it to MP3/Opus downstream.
    +    """
    +    import struct
    +
    +    byte_rate = sample_rate * channels * sample_width
    +    block_align = channels * sample_width
    +    data_size = len(pcm_bytes)
    +    fmt_chunk = struct.pack(
    +        "<4sIHHIIHH",
    +        b"fmt ",
    +        16,             # fmt chunk size (PCM)
    +        1,              # audio format (PCM)
    +        channels,
    +        sample_rate,
    +        byte_rate,
    +        block_align,
    +        sample_width * 8,
    +    )
    +    data_chunk_header = struct.pack("<4sI", b"data", data_size)
    +    riff_size = 4 + len(fmt_chunk) + len(data_chunk_header) + data_size
    +    riff_header = struct.pack("<4sI4s", b"RIFF", riff_size, b"WAVE")
    +    return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
    +
    +
    +def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
    +    """Generate audio using Google Gemini TTS.
    +
    +    Gemini's generateContent endpoint with responseModalities=["AUDIO"] returns
    +    raw 24kHz mono 16-bit PCM (L16) as base64. We wrap it with a WAV RIFF
    +    header to produce a playable file, then ffmpeg-convert to MP3 / Opus if
    +    the caller requested those formats (same pattern as NeuTTS).
    +
    +    Args:
    +        text: Text to convert (prompt-style; supports inline direction like
    +              "Say cheerfully:" and audio tags like [whispers]).
    +        output_path: Where to save the audio file (.wav, .mp3, or .ogg).
    +        tts_config: TTS config dict.
    +
    +    Returns:
    +        Path to the saved audio file.
    +    """
    +    import requests
    +
    +    api_key = [redacted:secret-assignment]"GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") or "").strip()
    +    if not api_key:
    +        raise ValueError(
    +            "GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey"
    +        )
    +
    +    gemini_config = tts_config.get("gemini", {})
    +    model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL
    +    voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE
    +    base_url = str(
    +        gemini_config.get("base_url")
    +        or os.getenv("GEMINI_BASE_URL")
    +        or DEFAULT_GEMINI_TTS_BASE_URL
    +    ).strip().rstrip("/")
    +
    +    payload: Dict[str, Any] = {
    +        "contents": [{"parts": [{"text": text}]}],
    +        "generationConfig": {
    +            "responseModalities": ["AUDIO"],
    +            "speechConfig": {
    +                "voiceConfig": {
    +                    "prebuiltVoiceConfig": {"voiceName": voice},
    +                },
    +            },
    +        },
    +    }
    +
    +    endpoint = f"{base_url}/models/{model}:generateContent"
    +    response = requests.post(
    +        endpoint,
    +        params={"key": api_key},
    +        headers={"Content-Type": "application/json"},
    +        json=payload,
    +        timeout=60,
    +    )
    +    if response.status_code != 200:
    +        # Surface the API error message when present
    +        try:
    +            err = response.json().get("error", {})
    +            detail = err.get("message") or response.text[:300]
    +        except Exception:
    +            detail = response.text[:300]
    +        raise RuntimeError(
    +            f"Gemini TTS API error (HTTP {response.status_code}): {detail}"
    +        )
    +
    +    try:
    +        data = response.json()
    +        parts = data["candidates"][0]["content"]["parts"]
    +        audio_part = next((p for p in parts if "inlineData" in p or "inline_data" in p), None)
    +        if audio_part is None:
    +            raise RuntimeError("Gemini TTS response contained no audio data")
    +        inline = audio_part.get("inlineData") or audio_part.get("inline_data") or {}
    +        audio_b64 = inline.get("data", "")
    +    except (KeyError, IndexError, TypeError) as e:
    +        raise RuntimeError(f"Gemini TTS response was malformed: {e}") from e
    +
    +    if not audio_b64:
    +        raise RuntimeError("Gemini TTS returned empty audio data")
    +
    +    pcm_bytes = base64.b64decode(audio_b64)
    +    wav_bytes = _wrap_pcm_as_wav(pcm_bytes)
    +
    +    # Fast path: caller wants WAV directly, just write.
    +    if output_path.lower().endswith(".wav"):
    +        with open(output_path, "wb") as f:
    +            f.write(wav_bytes)
    +        return output_path
    +
    +    # Otherwise write WAV to a temp file and ffmpeg-convert to the target
    +    # format (.mp3 or .ogg). If ffmpeg is missing, fall back to renaming the
    +    # WAV -- this matches the NeuTTS behavior and keeps the tool usable on
    +    # systems without ffmpeg (audio still plays, just with a misleading
    +    # extension).
    +    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
    +        tmp.write(wav_bytes)
    +        wav_path = tmp.name
    +
    +    try:
    +        ffmpeg = shutil.which("ffmpeg")
    +        if ffmpeg:
    +            # For .ogg output, force libopus encoding (Telegram voice bubbles
    +            # require Opus specifically; ffmpeg's default for .ogg is Vorbis).
    +            if output_path.lower().endswith(".ogg"):
    +                cmd = [
    +                    ffmpeg, "-i", wav_path,
    +                    "-acodec", "libopus", "-ac", "1",
    +                    "-b:a", "64k", "-vbr", "off",
    +                    "-y", "-loglevel", "error",
    +                    output_path,
    +                ]
    +            else:
    +                cmd = [ffmpeg, "-i", wav_path, "-y", "-loglevel", "error", output_path]
    +            result = subprocess.run(cmd, capture_output=True, timeout=30)
    +            if result.returncode != 0:
    +                stderr = result.stderr.decode("utf-8", errors="ignore")[:300]
    +                raise RuntimeError(f"ffmpeg conversion failed: {stderr}")
    +        else:
    +            logger.warning(
    +                "ffmpeg not found; writing raw WAV to %s (extension may be misleading)",
    +                output_path,
    +            )
    +            shutil.copyfile(wav_path, output_path)
    +    finally:
    +        try:
    +            os.remove(wav_path)
    +        except OSError:
    +            pass
    +
    +    return output_path
    +
    +
     # ===========================================================================
     # NeuTTS (local, on-device TTS via neutts_cli)
     # ===========================================================================
    ```

    ```diff
    @@ -634,7 +810,7 @@ def text_to_speech_tool(
             out_dir.mkdir(parents=True, exist_ok=True)
             # Use .ogg for Telegram with providers that support native Opus output,
             # otherwise fall back to .mp3 (Edge TTS will attempt ffmpeg conversion later).
    -        if want_opus and provider in ("openai", "elevenlabs", "mistral"):
    +        if want_opus and provider in ("openai", "elevenlabs", "mistral", "gemini"):
                 file_path = out_dir / f"tts_{timestamp}.ogg"
             else:
                 file_path = out_dir / f"tts_{timestamp}.mp3"
    ```

    ```diff
    @@ -687,6 +863,10 @@ def text_to_speech_tool(
                 logger.info("Generating speech with Mistral Voxtral TTS...")
                 _generate_mistral_tts(text, file_str, tts_config)
     
    +        elif provider == "gemini":
    +            logger.info("Generating speech with Google Gemini TTS...")
    +            _generate_gemini_tts(text, file_str, tts_config)
    +
             elif provider == "neutts":
                 if not _check_neutts_available():
                     return json.dumps({
    ```

    ```diff
    @@ -741,7 +921,7 @@ def text_to_speech_tool(
                 if opus_path:
                     file_str = opus_path
                     voice_compatible = True
    -        elif provider in ("elevenlabs", "openai", "mistral"):
    +        elif provider in ("elevenlabs", "openai", "mistral", "gemini"):
                 voice_compatible = file_str.endswith(".ogg")
     
             file_size = os.path.getsize(file_str)
    ```

    ```diff
    @@ -811,6 +991,8 @@ def check_tts_requirements() -> bool:
             return True
         if os.getenv("XAI_API_KEY"):
             return True
    +    if os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY"):
    +        return True
         try:
             _import_mistral_client()
             if os.getenv("MISTRAL_API_KEY"):
    ```
  - `website/docs/user-guide/features/tts.md`

    ```diff
    @@ -14,7 +14,7 @@ If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription,
     
     ## Text-to-Speech
     
    -Convert text to speech with six providers:
    +Convert text to speech with seven providers:
     
     | Provider | Quality | Cost | API Key |
     |----------|---------|------|---------|
    ```

    ```diff
    @@ -23,6 +23,7 @@ Convert text to speech with six providers:
     | **OpenAI TTS** | Good | Paid | `VOICE_TOOLS_OPENAI_KEY` |
     | **MiniMax TTS** | Excellent | Paid | `MINIMAX_API_KEY` |
     | **Mistral (Voxtral TTS)** | Excellent | Paid | `MISTRAL_API_KEY` |
    +| **Google Gemini TTS** | Excellent | Free tier | `GEMINI_API_KEY` |
     | **NeuTTS** | Good | Free | None needed |
     
     ### Platform Delivery
    ```

    ```diff
    @@ -39,7 +40,7 @@ Convert text to speech with six providers:
     ```yaml
     # In ~/.hermes/config.yaml
     tts:
    -  provider: "edge"              # "edge" | "elevenlabs" | "openai" | "minimax" | "mistral" | "neutts"
    +  provider: "edge"              # "edge" | "elevenlabs" | "openai" | "minimax" | "mistral" | "gemini" | "neutts"
       speed: 1.0                    # Global speed multiplier (provider-specific settings override this)
       edge:
         voice: "en-US-AriaNeural"   # 322 voices, 74 languages
    ```

    ```diff
    @@ -61,6 +62,9 @@ tts:
       mistral:
         model: "voxtral-mini-tts-2603"
         voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8"  # Paul - Neutral (default)
    +  gemini:
    +    model: "gemini-2.5-flash-preview-tts"  # or gemini-2.5-pro-preview-tts
    +    voice: "Kore"               # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, Gacrux, etc.
       neutts:
         ref_audio: ''
         ref_text: ''
    ```

    ```diff
    @@ -77,6 +81,7 @@ Telegram voice bubbles require Opus/OGG audio format:
     - **OpenAI, ElevenLabs, and Mistral** produce Opus natively — no extra setup
     - **Edge TTS** (default) outputs MP3 and needs **ffmpeg** to convert:
     - **MiniMax TTS** outputs MP3 and needs **ffmpeg** to convert for Telegram voice bubbles
    +- **Google Gemini TTS** outputs raw PCM and uses **ffmpeg** to encode Opus directly for Telegram voice bubbles
     - **NeuTTS** outputs WAV and also needs **ffmpeg** to convert for Telegram voice bubbles
     
     ```bash
    ```
- **#11091: feat(tts): add Gemini TTS provider**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#10922`, `#10918`, `#11229`

## Fix status

- Merged to main in PR #11229 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-16`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
