NousResearch/hermes-agent

Add Google Gemini TTS provider feature

Cluster 10918. Data as of

Nothing open on GitHub. Fix merged in #11229 on 16 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #11229. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #11229 feat(tts): add Google Gemini TTS provider

    merged to main

    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

    Show the diff
    • hermes_cli/setup.py
      @@ -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
      @@ -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)
      @@ -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)
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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
      @@ -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)
       # ===========================================================================
      @@ -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"
      @@ -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({
      @@ -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)
      @@ -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
      @@ -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 |
       |----------|---------|------|---------|
      @@ -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
      @@ -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
      @@ -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: ''
      @@ -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

2 PRs closed without merging

From the close comments: merged via another change 1 · superseded by another PR 1

  • #10922 feat(tts): add Gemini TTS provider · merged via another change → #11091
  • #11091 feat(tts): add Gemini TTS provider · superseded by another PR → #10922, #10918, #11229
Closed issues · 1
  • Closed#10918Feature: Add Google Gemini TTS as a speech-generation provider
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.50: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#11229
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
16 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.