{"repo":"NousResearch/hermes-agent","public_id":"8508","display_label":"Adding Piper TTS as local provider","summary":"These issues and pull requests concern integrating Piper as a native local text-to-speech provider, including setup wizard support, language coverage improvements, and a related local command provider addition.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17885,"merged_at":null},"resolution":{"state":"resolved","by":"pr-17885"},"freshness":{"state":"verified","last_verified_at":"2026-04-30T09:53:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":8508,"pr_number":17885,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":null}],"members":[{"kind":"issue","number":8508,"title":"[Feature]: Improve built-in local TTS language coverage with Piper","url":null,"state":"closed"},{"kind":"pr","number":7815,"title":"feat(tts): add Piper setup support and fix Telegram voice note metadata","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17885","number":17885},{"kind":"pr","value":"#36020","number":36020},{"kind":"pr","value":"#35439","number":35439},{"kind":"pr","value":"#35450","number":35450}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":17211,"title":"feat(tts): add local_command provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17211","number":17211},{"kind":"pr","value":"#17833","number":17833},{"kind":"pr","value":"#17843","number":17843},{"kind":"pr","value":"#17885","number":17885},{"kind":"pr","value":"#8508","number":8508},{"kind":"pr","value":"#11688","number":11688},{"kind":"pr","value":"#8508","number":8508},{"kind":"pr","value":"#17211","number":17211},{"kind":"pr","value":"#17211","number":17211}],"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":17885,"title":"feat(tts): add Piper as a native local TTS provider (closes #8508)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/tools_config.py","tests/tools/test_tts_command_providers.py","tests/tools/test_tts_mistral.py","tests/tools/test_tts_piper.py","tools/tts_tool.py","website/docs/user-guide/features/overview.md","website/docs/user-guide/features/tts.md"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -766,7 +766,7 @@ def _ensure_hermes_home_managed(home: Path):\n     # limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,\n     # Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).\n     \"tts\": {\n-        \"provider\": \"edge\",  # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"xai\" | \"minimax\" | \"mistral\" | \"neutts\" (local)\n+        \"provider\": \"edge\",  # \"edge\" (free) | \"elevenlabs\" (premium) | \"openai\" | \"xai\" | \"minimax\" | \"mistral\" | \"gemini\" | \"neutts\" (local) | \"kittentts\" (local) | \"piper\" (local)\n         \"edge\": {\n             \"voice\": \"en-US-AriaNeural\",\n             # Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural","@@ -796,6 +796,19 @@ def _ensure_hermes_home_managed(home: Path):\n             \"model\": \"neuphonic/neutts-air-q4-gguf\",  # HuggingFace model repo\n             \"device\": \"cpu\",  # cpu, cuda, or mps\n         },\n+        \"piper\": {\n+            # Voice name (e.g. \"en_US-lessac-medium\") downloaded on first\n+            # use, OR an absolute path to a pre-downloaded .onnx file.\n+            # Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\n+            \"voice\": \"en_US-lessac-medium\",\n+            # \"voices_dir\": \"\",        # Override voice cache dir; default = ~/.hermes/cache/piper-voices/\n+            # \"use_cuda\": False,       # Requires onnxruntime-gpu\n+            # \"length_scale\": 1.0,     # 2.0 = twice as slow\n+            # \"noise_scale\": 0.667,\n+            # \"noise_w_scale\": 0.8,\n+            # \"volume\": 1.0,\n+            # \"normalize_audio\": True,\n+        },\n     },\n     \n     \"stt\": {"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -227,6 +227,14 @@ def _get_plugin_toolset_keys() -> set:\n                 \"tts_provider\": \"kittentts\",\n                 \"post_setup\": \"kittentts\",\n             },\n+            {\n+                \"name\": \"Piper\",\n+                \"badge\": \"local · free\",\n+                \"tag\": \"Local neural TTS, 44 languages (voices ~20-90MB)\",\n+                \"env_vars\": [],\n+                \"tts_provider\": \"piper\",\n+                \"post_setup\": \"piper\",\n+            },\n         ],\n     },\n     \"web\": {","@@ -624,6 +632,33 @@ def _run_post_setup(post_setup_key: str):\n             _print_warning(\"    kittentts install timed out (>5min)\")\n             _print_info(f\"    Run manually: python -m pip install -U '{wheel_url}' soundfile\")\n \n+    elif post_setup_key == \"piper\":\n+        try:\n+            __import__(\"piper\")\n+            _print_success(\"    piper-tts is already installed\")\n+        except ImportError:\n+            import subprocess\n+            _print_info(\"    Installing piper-tts (~14MB wheel, voices downloaded on first use)...\")\n+            try:\n+                result = subprocess.run(\n+                    [sys.executable, \"-m\", \"pip\", \"install\", \"-U\", \"piper-tts\", \"--quiet\"],\n+                    capture_output=True, text=True, timeout=300,\n+                )\n+                if result.returncode == 0:\n+                    _print_success(\"    piper-tts installed\")\n+                else:\n+                    _print_warning(\"    piper-tts install failed:\")\n+                    _print_info(f\"      {result.stderr.strip()[:300]}\")\n+                    _print_info(\"    Run manually: python -m pip install -U piper-tts\")\n+                    return\n+            except subprocess.TimeoutExpired:\n+                _print_warning(\"    piper-tts install timed out (>5min)\")\n+                _print_info(\"    Run manually: python -m pip install -U piper-tts\")\n+                return\n+        _print_info(\"    Default voice: en_US-lessac-medium (downloaded on first TTS call)\")\n+        _print_info(\"    Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md\")\n+        _print_info(\"    Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml\")\n+\n     elif post_setup_key == \"spotify\":\n         # Run the full `hermes auth spotify` flow — if the user has no\n         # client_id yet, this drops them into the interactive wizard"]},{"file":"tests/tools/test_tts_command_providers.py","hunks":["@@ -81,29 +81,39 @@ def test_missing_provider_returns_none(self):\n     def test_user_declared_command_provider_resolves(self):\n         cfg = {\n             \"providers\": {\n-                \"piper\": {\"type\": \"command\", \"command\": \"piper foo\"},\n+                \"piper-cli\": {\"type\": \"command\", \"command\": \"piper-cli foo\"},\n             },\n         }\n-        resolved = _resolve_command_provider_config(\"piper\", cfg)\n+        resolved = _resolve_command_provider_config(\"piper-cli\", cfg)\n         assert resolved is not None\n-        assert resolved[\"command\"] == \"piper foo\"\n+        assert resolved[\"command\"] == \"piper-cli foo\"\n \n     def test_type_command_is_implied_when_command_is_set(self):\n-        cfg = {\"providers\": {\"piper\": {\"command\": \"piper foo\"}}}\n-        resolved = _resolve_command_provider_config(\"piper\", cfg)\n+        cfg = {\"providers\": {\"piper-cli\": {\"command\": \"piper-cli foo\"}}}\n+        resolved = _resolve_command_provider_config(\"piper-cli\", cfg)\n         assert resolved is not None\n \n     def test_other_type_values_reject(self):\n-        cfg = {\"providers\": {\"piper\": {\"type\": \"python\", \"command\": \"piper foo\"}}}\n-        assert _resolve_command_provider_config(\"piper\", cfg) is None\n+        cfg = {\"providers\": {\"piper-cli\": {\"type\": \"python\", \"command\": \"piper-cli foo\"}}}\n+        assert _resolve_command_provider_config(\"piper-cli\", cfg) is None\n \n     def test_empty_command_rejects(self):\n-        cfg = {\"providers\": {\"piper\": {\"type\": \"command\", \"command\": \"   \"}}}\n-        assert _resolve_command_provider_config(\"piper\", cfg) is None\n+        cfg = {\"providers\": {\"piper-cli\": {\"type\": \"command\", \"command\": \"   \"}}}\n+        assert _resolve_command_provider_config(\"piper-cli\", cfg) is None\n \n     def test_case_insensitive_lookup(self):\n-        cfg = {\"providers\": {\"piper\": {\"type\": \"command\", \"command\": \"x\"}}}\n-        assert _resolve_command_provider_config(\"PIPER\", cfg) is not None\n+        cfg = {\"providers\": {\"piper-cli\": {\"type\": \"command\", \"command\": \"x\"}}}\n+        assert _resolve_command_provider_config(\"PIPER-CLI\", cfg) is not None\n+\n+    def test_native_piper_cannot_be_shadowed_by_command_entry(self):\n+        \"\"\"Regression guard for PR that added native Piper as a built-in.\n+        A user's ``tts.providers.piper`` must not override the built-in.\"\"\"\n+        cfg = {\n+            \"providers\": {\n+                \"piper\": {\"type\": \"command\", \"command\": \"some-script\"},\n+            },\n+        }\n+        assert _resolve_command_provider_config(\"piper\", cfg) is None\n \n \n class TestGetNamedProviderConfig:","@@ -145,16 +155,16 @@ def test_iterates_only_user_command_providers(self):\n         cfg = {\n             \"providers\": {\n                 \"openai\": {\"type\": \"command\", \"command\": \"shouldnt show up\"},\n-                \"piper\": {\"type\": \"command\", \"command\": \"piper\"},\n+                \"piper-cli\": {\"type\": \"command\", \"command\": \"piper-cli\"},\n                 \"voxcpm\": {\"type\": \"command\", \"command\": \"voxcpm\"},\n                 \"broken\": {\"type\": \"command\", \"command\": \"\"},\n             },\n         }\n         names = sorted(name for name, _ in _iter_command_providers(cfg))\n-        assert names == [\"piper\", \"voxcpm\"]\n+        assert names == [\"piper-cli\", \"voxcpm\"]\n \n     def test_has_any_command_provider_detects_declared(self):\n-        cfg = {\"providers\": {\"piper\": {\"type\": \"command\", \"command\": \"piper\"}}}\n+        cfg = {\"providers\": {\"piper-cli\": {\"type\": \"command\", \"command\": \"piper-cli\"}}}\n         assert _has_any_command_tts_provider(cfg) is True\n \n     def test_has_any_command_provider_when_none(self):","@@ -216,16 +226,16 @@ def test_voice_compatible_default_off(self):\n \n class TestMaxTextLengthForCommandProviders:\n     def test_default_for_command_provider(self):\n-        cfg = {\"providers\": {\"piper\": {\"type\": \"command\", \"command\": \"x\"}}}\n-        assert _resolve_max_text_length(\"piper\", cfg) == DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH\n+        cfg = {\"providers\": {\"piper-cli\": {\"type\": \"command\", \"command\": \"x\"}}}\n+        assert _resolve_max_text_length(\"piper-cli\", cfg) == DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH\n \n     def test_override_under_providers(self):\n-        cfg = {\"providers\": {\"piper\": {\"type\": \"command\", \"command\": \"x\", \"max_text_length\": 2500}}}\n-        assert _resolve_max_text_length(\"piper\", cfg) == 2500\n+        cfg = {\"providers\": {\"piper-cli\": {\"type\": \"command\", \"command\": \"x\", \"max_text_length\": 2500}}}\n+        assert _resolve_max_text_length(\"piper-cli\", cfg) == 2500\n \n     def test_override_under_legacy_tts_name_block(self):\n-        cfg = {\"piper\": {\"type\": \"command\", \"command\": \"x\", \"max_text_length\": 7777}}\n-        assert _resolve_max_text_length(\"piper\", cfg) == 7777\n+        cfg = {\"piper-cli\": {\"type\": \"command\", \"command\": \"x\", \"max_text_length\": 7777}}\n+        assert _resolve_max_text_length(\"piper-cli\", cfg) == 7777\n \n     def test_non_command_unknown_provider_still_falls_back(self):\n         assert _resolve_max_text_length(\"unknown\", {}) > 0"]},{"file":"tests/tools/test_tts_mistral.py","hunks":["@@ -216,5 +216,8 @@ def test_mistral_key_missing_returns_false(self, mock_mistral_module):\n         with patch(\"tools.tts_tool._import_edge_tts\", side_effect=ImportError), \\\n              patch(\"tools.tts_tool._import_elevenlabs\", side_effect=ImportError), \\\n              patch(\"tools.tts_tool._import_openai_client\", side_effect=ImportError), \\\n-             patch(\"tools.tts_tool._check_neutts_available\", return_value=False):\n+             patch(\"tools.tts_tool._check_neutts_available\", return_value=False), \\\n+             patch(\"tools.tts_tool._check_kittentts_available\", return_value=False), \\\n+             patch(\"tools.tts_tool._check_piper_available\", return_value=False), \\\n+             patch(\"tools.tts_tool._has_any_command_tts_provider\", return_value=False):\n             assert check_tts_requirements() is False"]},{"file":"tests/tools/test_tts_piper.py","hunks":["@@ -0,0 +1,306 @@\n+\"\"\"\n+Tests for the native Piper TTS provider.\n+\n+These tests pin the resolution / caching / dispatch paths for Piper\n+without requiring the ``piper-tts`` package to actually be installed\n+(the synthesis step is monkey-patched to avoid needing the ONNX wheel).\n+\"\"\"\n+\n+import json\n+import os\n+import sys\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from tools import tts_tool\n+from tools.tts_tool import (\n+    BUILTIN_TTS_PROVIDERS,\n+    DEFAULT_PIPER_VOICE,\n+    PROVIDER_MAX_TEXT_LENGTH,\n+    _check_piper_available,\n+    _resolve_piper_voice_path,\n+    check_tts_requirements,\n+    text_to_speech_tool,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Registry / constants\n+# ---------------------------------------------------------------------------\n+\n+class TestPiperRegistration:\n+    def test_piper_is_a_builtin_provider(self):\n+        assert \"piper\" in BUILTIN_TTS_PROVIDERS\n+\n+    def test_piper_has_a_text_length_cap(self):\n+        assert PROVIDER_MAX_TEXT_LENGTH.get(\"piper\", 0) > 0\n+\n+\n+# ---------------------------------------------------------------------------\n+# _check_piper_available\n+# ---------------------------------------------------------------------------\n+\n+class TestCheckPiperAvailable:\n+    def test_returns_bool_without_raising(self):\n+        # We don't care about the current environment's answer — just that\n+        # the probe never raises on a machine without piper installed.\n+        assert isinstance(_check_piper_available(), bool)\n+\n+\n+# ---------------------------------------------------------------------------\n+# _resolve_piper_voice_path\n+# ---------------------------------------------------------------------------\n+\n+class TestResolvePiperVoicePath:\n+    def test_direct_onnx_path_returned_as_is(self, tmp_path):\n+        model = tmp_path / \"custom.onnx\"\n+        model.write_bytes(b\"fake onnx bytes\")\n+        result = _resolve_piper_voice_path(str(model), tmp_path)\n+        assert result == str(model)\n+\n+    def test_cached_voice_name_not_redownloaded(self, tmp_path):\n+        \"\"\"If both <voice>.onnx and <voice>.onnx.json exist in the\n+        download dir, no subprocess is spawned.\"\"\"\n+        voice = \"en_US-test-medium\"\n+        (tmp_path / f\"{voice}.onnx\").write_bytes(b\"model\")\n+        (tmp_path / f\"{voice}.onnx.json\").write_text(\"{}\")\n+\n+        with patch(\"tools.tts_tool.subprocess.run\") as mock_run:\n+            result = _resolve_piper_voice_path(voice, tmp_path)\n+\n+        mock_run.assert_not_called()\n+        assert result == str(tmp_path / f\"{voice}.onnx\")\n+\n+    def test_missing_voice_triggers_download(self, tmp_path):\n+        voice = \"en_US-new-medium\"\n+\n+        def fake_run(cmd, *a, **kw):\n+            # Simulate a successful download: write the expected files.\n+            (tmp_path / f\"{voice}.onnx\").write_bytes(b\"model\")\n+            (tmp_path / f\"{voice}.onnx.json\").write_text(\"{}\")\n+            return MagicMock(returncode=0, stderr=\"\", stdout=\"\")\n+\n+        with patch(\"tools.tts_tool.subprocess.run\", side_effect=fake_run) as mock_run:\n+            result = _resolve_piper_voice_path(voice, tmp_path)\n+\n+        mock_run.assert_called_once()\n+        # Verify the command shape: python -m piper.download_voices <voice> --download-dir <dir>\n+        call_args = mock_run.call_args.args[0]\n+        assert \"piper.download_voices\" in \" \".join(call_args)\n+        assert voice in call_args\n+        assert \"--download-dir\" in call_args\n+        assert str(tmp_path) in call_args\n+        assert result == str(tmp_path / f\"{voice}.onnx\")\n+\n+    def test_download_failure_raises_runtime(self, tmp_path):\n+        voice = \"en_US-broken-medium\"\n+        fake_result = MagicMock(returncode=1, stderr=\"voice not found\", stdout=\"\")\n+        with patch(\"tools.tts_tool.subprocess.run\", return_value=fake_result):\n+            with pytest.raises(RuntimeError, match=\"Piper voice download failed\"):\n+                _resolve_piper_voice_path(voice, tmp_path)\n+\n+    def test_download_success_but_missing_file_raises(self, tmp_path):\n+        voice = \"en_US-weird-medium\"\n+        fake_result = MagicMock(returncode=0, stderr=\"\", stdout=\"\")\n+        # Subprocess \"succeeds\" but doesn't actually write the files.\n+        with patch(\"tools.tts_tool.subprocess.run\", return_value=fake_result):\n+            with pytest.raises(RuntimeError, match=\"completed but .+ is missing\"):\n+                _resolve_piper_voice_path(voice, tmp_path)\n+\n+    def test_empty_voice_falls_back_to_default_name(self, tmp_path):\n+        (tmp_path / f\"{DEFAULT_PIPER_VOICE}.onnx\").write_bytes(b\"model\")\n+        (tmp_path / f\"{DEFAULT_PIPER_VOICE}.onnx.json\").write_text(\"{}\")\n+        result = _resolve_piper_voice_path(\"\", tmp_path)\n+        assert result.endswith(f\"{DEFAULT_PIPER_VOICE}.onnx\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# _generate_piper_tts — stubbed so we don't need piper-tts installed\n+# ---------------------------------------------------------------------------\n+\n+class _StubPiperVoice:\n+    \"\"\"Stand-in for piper.PiperVoice used by the synthesis tests.\"\"\"\n+\n+    loaded: list[str] = []\n+    calls: list[tuple] = []\n+\n+    @classmethod\n+    def load(cls, model_path, use_cuda=False):\n+        cls.loaded.append(model_path)\n+        instance = cls()\n+        instance.model_path = model_path\n+        instance.use_cuda = use_cuda\n+        return instance\n+\n+    def synthesize_wav(self, text, wav_file, syn_config=None):\n+        # Minimal valid WAV: an empty frame set is fine for our size check.\n+        # The wave module accepts any frames; we just need the file to exist\n+        # with non-zero bytes after close.\n+        wav_file.setnchannels(1)\n+        wav_file.setsampwidth(2)\n+        wav_file.setframerate(22050)\n+        wav_file.writeframes(b\"\\x00\\x00\" * 1024)\n+        _StubPiperVoice.calls.append((text, getattr(self, \"model_path\", \"\"), syn_config))\n+\n+\n[redacted:email](autouse=True)\n+def _reset_piper_cache():\n+    \"\"\"Clear the module-level voice cache between tests.\"\"\"\n+    tts_tool._piper_voice_cache.clear()\n+    _StubPiperVoice.loaded = []\n+    _StubPiperVoice.calls = []\n+    yield\n+    tts_tool._piper_voice_cache.clear()\n+\n+\n+class TestGeneratePiperTts:\n+    def _prepare_voice_files(self, tmp_path, voice=DEFAULT_PIPER_VOICE):\n+        model = tmp_path / f\"{voice}.onnx\"\n+        model.write_bytes(b\"model\")\n+        (tmp_path / f\"{voice}.onnx.json\").write_text(\"{}\")\n+        return model\n+\n+    def test_loads_voice_and_writes_wav(self, tmp_path, monkeypatch):\n+        model = self._prepare_voice_files(tmp_path)\n+        monkeypatch.setattr(tts_tool, \"_import_piper\", lambda: _StubPiperVoice)\n+\n+        out_path = str(tmp_path / \"out.wav\")\n+        config = {\"piper\": {\"voice\": str(model)}}\n+\n+        result = tts_tool._generate_piper_tts(\"hello\", out_path, config)\n+\n+        assert result == out_path\n+        assert Path(out_path).exists()\n+        assert Path(out_path).stat().st_size > 0\n+        assert _StubPiperVoice.loaded == [str(model)]\n+        assert _StubPiperVoice.calls[0][0] == \"hello\"\n+\n+    def test_voice_cache_reused_across_calls(self, tmp_path, monkeypatch):\n+        model = self._prepare_voice_files(tmp_path)\n+        monkeypatch.setattr(tts_tool, \"_import_piper\", lambda: _StubPiperVoice)\n+\n+        config = {\"piper\": {\"voice\": str(model)}}\n+        tts_tool._generate_piper_tts(\"one\", str(tmp_path / \"a.wav\"), config)\n+        tts_tool._generate_piper_tts(\"two\", str(tmp_path / \"b.wav\"), config)\n+\n+        # load() should have been called exactly once for the same model+cuda key.\n+        assert _StubPiperVoice.loaded == [str(model)]\n+        # But both synthesize calls went through.\n+        assert [c[0] for c in _StubPiperVoice.calls] == [\"one\", \"two\"]\n+\n+    def test_voice_name_triggers_download(self, tmp_path, monkeypatch):\n+        \"\"\"A config voice of ``en_US-lessac-medium`` should be resolved via\n+        _resolve_piper_voice_path (which would normally download).\"\"\"\n+        monkeypatch.setattr(tts_tool, \"_import_piper\", lambda: _StubPiperVoice)\n+\n+        def fake_resolve(voice, download_dir):\n+            model = download_dir / f\"{voice}.onnx\"\n+            model.write_bytes(b\"model\")\n+            return str(model)\n+\n+        monkeypatch.setattr(tts_tool, \"_resolve_piper_voice_path\", fake_resolve)\n+\n+        config = {\"piper\": {\"voice\": \"en_US-lessac-medium\", \"voices_dir\": str(tmp_path)}}\n+        result = tts_tool._generate_piper_tts(\"hi\", str(tmp_path / \"out.wav\"), config)\n+\n+        assert Path(result).exists()\n+        assert _StubPiperVoice.loaded[0].endswith(\"en_US-lessac-medium.onnx\")\n+\n+    def test_advanced_knobs_passed_as_synconfig(self, tmp_path, monkeypatch):\n+        model = self._prepare_voice_files(tmp_path)\n+        monkeypatch.setattr(tts_tool, \"_import_piper\", lambda: _StubPiperVoice)\n+\n+        # Fake SynthesisConfig so we can assert the knobs flowed through.\n+        fake_syn_cls = MagicMock()\n+\n+        class FakePiperModule:\n+            SynthesisConfig = fake_syn_cls\n+\n+        # The SynthesisConfig import happens inline inside _generate_piper_tts\n+        # via ``from piper import SynthesisConfig``. Inject a fake piper\n+        # module so that import resolves.\n+        monkeypatch.setitem(sys.modules, \"piper\", FakePiperModule)\n+\n+        config = {\n+            \"piper\": {\n+                \"voice\": str(model),\n+                \"length_scale\": 2.0,\n+                \"volume\": 0.8,\n+            },\n+        }\n+        tts_tool._generate_piper_tts(\n+            \"slow voice\", str(tmp_path / \"out.wav\"), config,\n+        )\n+\n+        # SynthesisConfig was constructed with the advanced knobs.\n+        fake_syn_cls.assert_called_once()\n+        kwargs = fake_syn_cls.call_args.kwargs\n+        assert kwargs[\"length_scale\"] == 2.0\n+        assert kwargs[\"volume\"] == 0.8\n+\n+\n+# ---------------------------------------------------------------------------\n+# text_to_speech_tool end-to-end (provider == \"piper\")\n+# ---------------------------------------------------------------------------\n+\n+class TestTextToSpeechToolWithPiper:\n+    def test_dispatches_to_piper(self, tmp_path, monkeypatch):\n+        model = tmp_path / f\"{DEFAULT_PIPER_VOICE}.onnx\"\n+        model.write_bytes(b\"model\")\n+        (tmp_path / f\"{DEFAULT_PIPER_VOICE}.onnx.json\").write_text(\"{}\")\n+\n+        monkeypatch.setattr(tts_tool, \"_import_piper\", lambda: _StubPiperVoice)\n+\n+        cfg = {\"provider\": \"piper\", \"piper\": {\"voice\": str(model)}}\n+        monkeypatch.setattr(tts_tool, \"_load_tts_config\", lambda: cfg)\n+\n+        result = text_to_speech_tool(text=\"hi\", output_path=str(tmp_path / \"clip.wav\"))\n+        data = json.loads(result)\n+\n+        assert data[\"success\"] is True, data\n+        assert data[\"provider\"] == \"piper\"\n+        assert Path(data[\"file_path\"]).exists()\n+\n+    def test_missing_package_surfaces_error(self, tmp_path, monkeypatch):\n+        def raise_import():\n+            raise ImportError(\"No module named 'piper'\")\n+\n+        monkeypatch.setattr(tts_tool, \"_import_piper\", raise_import)\n+\n+        cfg = {\"provider\": \"piper\"}\n+        monkeypatch.setattr(tts_tool, \"_load_tts_config\", lambda: cfg)\n+\n+        result = text_to_speech_tool(text=\"hi\", output_path=str(tmp_path / \"clip.wav\"))\n+        data = json.loads(result)\n+\n+        assert data[\"success\"] is False\n+        assert \"piper-tts\" in data[\"error\"]\n+\n+\n+# ---------------------------------------------------------------------------\n+# check_tts_requirements\n+# ---------------------------------------------------------------------------\n+\n+class TestCheckTtsRequirementsPiper:\n+    def test_piper_install_satisfies_requirements(self, monkeypatch):\n+        # Drop every other provider so we can isolate the piper signal.\n+        monkeypatch.setattr(tts_tool, \"_import_edge_tts\", lambda: (_ for _ in ()).throw(ImportError()))\n+        monkeypatch.setattr(tts_tool, \"_import_elevenlabs\", lambda: (_ for _ in ()).throw(ImportError()))\n+        monkeypatch.setattr(tts_tool, \"_import_openai_client\", lambda: (_ for _ in ()).throw(ImportError()))\n+        monkeypatch.setattr(tts_tool, \"_import_mistral_client\", lambda: (_ for _ in ()).throw(ImportError()))\n+        monkeypatch.setattr(tts_tool, \"_check_neutts_available\", lambda: False)\n+        monkeypatch.setattr(tts_tool, \"_check_kittentts_available\", lambda: False)\n+        monkeypatch.setattr(tts_tool, \"_has_any_command_tts_provider\", lambda: False)\n+        monkeypatch.setattr(tts_tool, \"_has_openai_audio_backend\", lambda: False)\n+        for env in (\"MINIMAX_API_KEY\", \"XAI_API_KEY\", \"GEMINI_API_KEY\",\n+                    \"GOOGLE_API_KEY\", \"MISTRAL_API_KEY\", \"ELEVENLABS_API_KEY\"):\n+            monkeypatch.delenv(env, raising=False)\n+\n+        # Now toggle the piper check on and off.\n+        monkeypatch.setattr(tts_tool, \"_check_piper_available\", lambda: False)\n+        assert check_tts_requirements() is False\n+\n+        monkeypatch.setattr(tts_tool, \"_check_piper_available\", lambda: True)\n+        assert check_tts_requirements() is True"]},{"file":"tools/tts_tool.py","hunks":["@@ -12,6 +12,7 @@\n - xAI TTS: Grok voices, needs XAI_API_KEY\n - NeuTTS (local, free, no API key): On-device TTS via neutts\n - KittenTTS (local, free, no API key): On-device 25MB model\n+- Piper (local, free, no API key): OHF-Voice/piper1-gpl neural VITS, 44 languages\n \n Custom command providers:\n - Users can declare any number of named providers with ``type: command``","@@ -109,6 +110,18 @@ def _import_kittentts():\n     return KittenTTS\n \n \n+def _import_piper():\n+    \"\"\"Lazy import Piper. Returns the PiperVoice class or raises ImportError.\n+\n+    Piper is an optional, fully-local neural TTS engine (Home Assistant /\n+    Open Home Foundation). ``pip install piper-tts`` provides cross-platform\n+    wheels (Linux / macOS / Windows, x86_64 + ARM64) with embedded espeak-ng.\n+    Voice models (.onnx + .onnx.json) are downloaded on first use.\n+    \"\"\"\n+    from piper import PiperVoice\n+    return PiperVoice\n+\n+\n # ===========================================================================\n # Defaults\n # ===========================================================================","@@ -120,6 +133,7 @@ def _import_kittentts():\n DEFAULT_OPENAI_MODEL = \"gpt-4o-mini-tts\"\n DEFAULT_KITTENTTS_MODEL = \"KittenML/kitten-tts-nano-0.8-int8\"  # 25MB\n DEFAULT_KITTENTTS_VOICE = \"Jasper\"\n+DEFAULT_PIPER_VOICE = \"en_US-lessac-medium\"  # balanced size/quality\n DEFAULT_OPENAI_VOICE = \"alloy\"\n DEFAULT_OPENAI_BASE_URL = \"https://api.openai.com/v1\"\n DEFAULT_MINIMAX_MODEL = \"speech-2.8-hd\"","@@ -163,6 +177,7 @@ def _get_default_output_dir() -> str:\n     \"elevenlabs\": 10000,  # fallback when model-aware lookup can't resolve (multilingual_v2)\n     \"neutts\": 2000,       # local model, quality falls off on long text\n     \"kittentts\": 2000,    # local 25MB model\n+    \"piper\": 5000,        # local VITS model, phoneme-based; practical cap\n }\n \n # ElevenLabs caps vary by model_id. https://elevenlabs.io/docs/overview/models","@@ -308,6 +323,7 @@ def _get_provider(tts_config: Dict[str, Any]) -> str:\n     \"gemini\",\n     \"neutts\",\n     \"kittentts\",\n+    \"piper\",\n })\n \n DEFAULT_COMMAND_TTS_TIMEOUT_SECONDS = 120","@@ -1293,6 +1309,167 @@ def _generate_neutts(text: str, output_path: str, tts_config: Dict[str, Any]) ->\n     return output_path\n \n \n+# ===========================================================================\n+# Provider: Piper (local, neural VITS, 44 languages)\n+# ===========================================================================\n+\n+# Module-level cache for Piper voice instances. Voices are keyed on their\n+# absolute .onnx model path so switching voices doesn't invalidate older\n+# cached voices.\n+_piper_voice_cache: Dict[str, Any] = {}\n+\n+\n+def _check_piper_available() -> bool:\n+    \"\"\"Check whether the piper-tts package is importable.\"\"\"\n+    try:\n+        import importlib.util\n+        return importlib.util.find_spec(\"piper\") is not None\n+    except Exception:\n+        return False\n+\n+\n+def _get_piper_voices_dir() -> Path:\n+    \"\"\"Return the directory where Hermes caches Piper voice models.\n+\n+    Resolves to ``~/.hermes/cache/piper-voices/`` under the active\n+    HERMES_HOME so voice downloads follow profile boundaries.\n+    \"\"\"\n+    from hermes_constants import get_hermes_dir\n+    root = Path(get_hermes_dir(\"cache/piper-voices\", \"piper_voices_cache\"))\n+    root.mkdir(parents=True, exist_ok=True)\n+    return root\n+\n+\n+def _resolve_piper_voice_path(voice: str, download_dir: Path) -> str:\n+    \"\"\"Resolve *voice* (a model name or path) to a concrete .onnx file path.\n+\n+    Accepts any of:\n+      - Absolute / expanded path to an .onnx file the user already has\n+      - A voice *name* like ``en_US-lessac-medium`` (downloads to\n+        ``download_dir`` on first use via ``python -m piper.download_voices``)\n+\n+    Raises RuntimeError if the model can't be located or downloaded.\n+    \"\"\"\n+    if not voice:\n+        voice = DEFAULT_PIPER_VOICE\n+\n+    # Case 1: user gave a direct file path.\n+    candidate = Path(voice).expanduser()\n+    if candidate.suffix.lower() == \".onnx\" and candidate.exists():\n+        return str(candidate)\n+\n+    # Case 2: user gave a voice *name*. See if it's already downloaded.\n+    cached = download_dir / f\"{voice}.onnx\"\n+    if cached.exists() and (download_dir / f\"{voice}.onnx.json\").exists():\n+        return str(cached)\n+\n+    # Case 3: download the voice. piper ships a download helper module.\n+    import sys as _sys\n+    logger.info(\"[Piper] Downloading voice '%s' to %s (first use)\", voice, download_dir)\n+    try:\n+        result = subprocess.run(\n+            [_sys.executable, \"-m\", \"piper.download_voices\", voice,\n+             \"--download-dir\", str(download_dir)],\n+            capture_output=True, text=True, timeout=300,\n+        )\n+    except subprocess.TimeoutExpired as exc:\n+        raise RuntimeError(\n+            f\"Piper voice download timed out after 300s for '{voice}'\"\n+        ) from exc\n+\n+    if result.returncode != 0:\n+        stderr = (result.stderr or \"\").strip() or \"no stderr output\"\n+        raise RuntimeError(\n+            f\"Piper voice download failed for '{voice}': {stderr[:400]}\"\n+        )\n+\n+    if not cached.exists():\n+        raise RuntimeError(\n+            f\"Piper voice download completed but {cached} is missing — \"\n+            f\"check voice name (see: https://github.com/OHF-Voice/piper1-gpl/\"\n+            f\"blob/main/docs/VOICES.md)\"\n+        )\n+    return str(cached)\n+\n+\n+def _generate_piper_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:\n+    \"\"\"Generate speech using the local Piper engine.\n+\n+    Loads the voice model once per process (cached by absolute path) and\n+    writes a WAV file. Caller is responsible for converting to MP3/Opus\n+    via ffmpeg when a different output format is required.\n+    \"\"\"\n+    PiperVoice = _import_piper()\n+    import wave\n+\n+    piper_config = tts_config.get(\"piper\", {}) if isinstance(tts_config, dict) else {}\n+    voice_name = piper_config.get(\"voice\") or DEFAULT_PIPER_VOICE\n+    download_dir = Path(piper_config.get(\"voices_dir\") or _get_piper_voices_dir()).expanduser()\n+    download_dir.mkdir(parents=True, exist_ok=True)\n+    use_cuda = bool(piper_config.get(\"use_cuda\", False))\n+\n+    model_path = _resolve_piper_voice_path(voice_name, download_dir)\n+\n+    cache_key = f\"{model_path}::cuda={use_cuda}\"\n+    global _piper_voice_cache\n+    if cache_key not in _piper_voice_cache:\n+        logger.info(\"[Piper] Loading voice: %s\", model_path)\n+        _piper_voice_cache[cache_key] = PiperVoice.load(model_path, use_cuda=use_cuda)\n+        logger.info(\"[Piper] Voice loaded\")\n+    voice = _piper_voice_cache[cache_key]\n+\n+    # Optional synthesis knobs — only pass a SynthesisConfig when at least\n+    # one advanced knob is configured, so we don't depend on a newer Piper\n+    # version than the user's installed one unless we need to.\n+    syn_config = None\n+    has_advanced = any(\n+        k in piper_config\n+        for k in (\"length_scale\", \"noise_scale\", \"noise_w_scale\", \"volume\", \"normalize_audio\")\n+    )\n+    if has_advanced:\n+        try:\n+            from piper import SynthesisConfig  # type: ignore\n+            syn_config = SynthesisConfig(\n+                length_scale=float(piper_config.get(\"length_scale\", 1.0)),\n+                noise_scale=float(piper_config.get(\"noise_scale\", 0.667)),\n+                noise_w_scale=float(piper_config.get(\"noise_w_scale\", 0.8)),\n+                volume=float(piper_config.get(\"volume\", 1.0)),\n+                normalize_audio=bool(piper_config.get(\"normalize_audio\", True)),\n+            )\n+        except ImportError:\n+            logger.warning(\n+                \"[Piper] SynthesisConfig not available in this piper-tts \"\n+                \"version — advanced knobs ignored\"\n+            )\n+\n+    # Piper outputs WAV. Caller handles downstream MP3/Opus conversion.\n+    wav_path = output_path\n+    if not output_path.endswith(\".wav\"):\n+        wav_path = output_path.rsplit(\".\", 1)[0] + \".wav\"\n+\n+    with wave.open(wav_path, \"wb\") as wav_file:\n+        if syn_config is not None:\n+            voice.synthesize_wav(text, wav_file, syn_config=syn_config)\n+        else:\n+            voice.synthesize_wav(text, wav_file)\n+\n+    # Convert to desired format if caller requested mp3/ogg\n+    if wav_path != output_path:\n+        ffmpeg = shutil.which(\"ffmpeg\")\n+        if ffmpeg:\n+            conv_cmd = [ffmpeg, \"-i\", wav_path, \"-y\", \"-loglevel\", \"error\", output_path]\n+            subprocess.run(conv_cmd, check=True, timeout=30)\n+            try:\n+                os.remove(wav_path)\n+            except OSError:\n+                pass\n+        else:\n+            # No ffmpeg — keep WAV and return that path\n+            os.rename(wav_path, output_path)\n+\n+    return output_path\n+\n+\n # ===========================================================================\n # Provider: KittenTTS (local, lightweight)\n # ===========================================================================","@@ -1517,6 +1694,19 @@ def text_to_speech_tool(\n             logger.info(\"Generating speech with KittenTTS (local, ~25MB)...\")\n             _generate_kittentts(text, file_str, tts_config)\n \n+        elif provider == \"piper\":\n+            try:\n+                _import_piper()\n+            except ImportError:\n+                return json.dumps({\n+                    \"success\": False,\n+                    \"error\": \"Piper provider selected but 'piper-tts' package not installed. \"\n+                             \"Run 'hermes tools' and select Piper under TTS, or install manually: \"\n+                             \"pip install piper-tts\",\n+                }, ensure_ascii=False)\n+            logger.info(\"Generating speech with Piper (local)...\")\n+            _generate_piper_tts(text, file_str, tts_config)\n+\n         else:\n             # Default: Edge TTS (free), with NeuTTS as local fallback\n             edge_available = True","@@ -1566,7 +1756,7 @@ def text_to_speech_tool(\n                     if opus_path:\n                         file_str = opus_path\n                 voice_compatible = file_str.endswith(\".ogg\")\n-        elif provider in (\"edge\", \"neutts\", \"minimax\", \"xai\", \"kittentts\") and not file_str.endswith(\".ogg\"):\n+        elif provider in (\"edge\", \"neutts\", \"minimax\", \"xai\", \"kittentts\", \"piper\") and not file_str.endswith(\".ogg\"):\n             opus_path = _convert_to_opus(file_str)\n             if opus_path:\n                 file_str = opus_path","@@ -1657,6 +1847,8 @@ def check_tts_requirements() -> bool:\n         return True\n     if _check_kittentts_available():\n         return True\n+    if _check_piper_available():\n+        return True\n     return False\n \n ","@@ -1954,6 +2146,7 @@ def _check(importer, label):\n         f\"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}\"\n     )\n     print(f\"  MiniMax:    {'API key set' if get_env_value('MINIMAX_API_KEY') else 'not set (MINIMAX_API_KEY)'}\")\n+    print(f\"  Piper:      {'installed' if _check_piper_available() else 'not installed (pip install piper-tts)'}\")\n     print(f\"  ffmpeg:     {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}\")\n     print(f\"\\n  Output dir: {DEFAULT_OUTPUT_DIR}\")\n "]},{"file":"website/docs/user-guide/features/overview.md","hunks":["@@ -31,7 +31,7 @@ Hermes Agent includes a rich set of capabilities that extend far beyond basic ch\n - **[Browser Automation](browser.md)** — Full browser automation with multiple backends: Browserbase cloud, Browser Use cloud, local Chrome via CDP, or local Chromium. Navigate websites, fill forms, and extract information.\n - **[Vision & Image Paste](vision.md)** — Multimodal vision support. Paste images from your clipboard into the CLI and ask the agent to analyze, describe, or work with them using any vision-capable model.\n - **[Image Generation](image-generation.md)** — Generate images from text prompts using FAL.ai. Nine models supported (FLUX 2 Klein/Pro, GPT-Image 1.5/2, Nano Banana Pro, Ideogram V3, Recraft V4 Pro, Qwen, Z-Image Turbo); pick one via `hermes tools`.\n-- **[Voice & TTS](tts.md)** — Text-to-speech output and voice message transcription across all messaging platforms, with nine provider options: Edge TTS (free), ElevenLabs, OpenAI TTS, MiniMax, Mistral Voxtral, Google Gemini, xAI, NeuTTS, and KittenTTS.\n+- **[Voice & TTS](tts.md)** — Text-to-speech output and voice message transcription across all messaging platforms, with ten native provider options: Edge TTS (free), ElevenLabs, OpenAI TTS, MiniMax, Mistral Voxtral, Google Gemini, xAI, NeuTTS, KittenTTS, and Piper — plus custom command providers for any local TTS CLI.\n \n ## Integrations\n "]},{"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 nine providers:\n+Convert text to speech with ten providers:\n \n | Provider | Quality | Cost | API Key |\n |----------|---------|------|---------|","@@ -27,6 +27,7 @@ Convert text to speech with nine providers:\n | **xAI TTS** | Excellent | Paid | `XAI_API_KEY` |\n | **NeuTTS** | Good | Free (local) | None needed |\n | **KittenTTS** | Good | Free (local) | None needed |\n+| **Piper** | Good | Free (local) | None needed |\n \n ### Platform Delivery\n ","@@ -42,7 +43,7 @@ Convert text to speech with nine providers:\n ```yaml\n # In ~/.hermes/config.yaml\n tts:\n-  provider: \"edge\"              # \"edge\" | \"elevenlabs\" | \"openai\" | \"minimax\" | \"mistral\" | \"gemini\" | \"xai\" | \"neutts\" | \"kittentts\"\n+  provider: \"edge\"              # \"edge\" | \"elevenlabs\" | \"openai\" | \"minimax\" | \"mistral\" | \"gemini\" | \"xai\" | \"neutts\" | \"kittentts\" | \"piper\"\n   speed: 1.0                    # Global speed multiplier (provider-specific settings override this)\n   edge:\n     voice: \"en-US-AriaNeural\"   # 322 voices, 74 languages","@@ -83,6 +84,15 @@ tts:\n     voice: Jasper                               # Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo\n     speed: 1.0                                  # 0.5 - 2.0\n     clean_text: true                            # Expand numbers, currencies, units\n+  piper:\n+    voice: en_US-lessac-medium                  # voice name (auto-downloaded) OR absolute path to .onnx\n+    # voices_dir: ''                            # default: ~/.hermes/cache/piper-voices/\n+    # use_cuda: false                           # requires onnxruntime-gpu\n+    # length_scale: 1.0                         # 2.0 = twice as slow\n+    # noise_scale: 0.667\n+    # noise_w_scale: 0.8\n+    # volume: 1.0                               # 0.5 = half as loud\n+    # normalize_audio: true\n ```\n \n **Speed control**: The global `tts.speed` value applies to all providers by default. Each provider can override it with its own `speed` setting (e.g., `tts.openai.speed: 1.5`). Provider-specific speed takes precedence over the global value. Default is `1.0` (normal speed).","@@ -98,6 +108,7 @@ Telegram voice bubbles require Opus/OGG audio format:\n - **xAI TTS** outputs MP3 and needs **ffmpeg** to convert for Telegram voice bubbles\n - **NeuTTS** outputs WAV and also needs **ffmpeg** to convert for Telegram voice bubbles\n - **KittenTTS** outputs WAV and also needs **ffmpeg** to convert for Telegram voice bubbles\n+- **Piper** outputs WAV and also needs **ffmpeg** to convert for Telegram voice bubbles\n \n ```bash\n # Ubuntu/Debian","@@ -110,27 +121,51 @@ brew install ffmpeg\n sudo dnf install ffmpeg\n ```\n \n-Without ffmpeg, Edge TTS, MiniMax TTS, NeuTTS, and KittenTTS audio are sent as regular audio files (playable, but shown as a rectangular player instead of a voice bubble).\n+Without ffmpeg, Edge TTS, MiniMax TTS, NeuTTS, KittenTTS, and Piper audio are sent as regular audio files (playable, but shown as a rectangular player instead of a voice bubble).\n \n :::tip\n If you want voice bubbles without installing ffmpeg, switch to the OpenAI, ElevenLabs, or Mistral provider.\n :::\n \n+### Piper (local, 44 languages)\n+\n+Piper is a fast, local neural TTS engine from the Open Home Foundation (the Home Assistant maintainers). It runs entirely on CPU, supports **44 languages** with pre-trained voices, and needs no API key.\n+\n+**Install via `hermes tools`** → Voice & TTS → Piper — Hermes runs `pip install piper-tts` for you. Or install manually: `pip install piper-tts`.\n+\n+**Switch to Piper:**\n+\n+```yaml\n+tts:\n+  provider: piper\n+  piper:\n+    voice: en_US-lessac-medium\n+```\n+\n+On the first TTS call for a voice that isn't cached locally, Hermes runs `python -m piper.download_voices <name>` and downloads the model (~20-90MB depending on quality tier) into `~/.hermes/cache/piper-voices/`. Subsequent calls reuse the cached model.\n+\n+**Picking a voice.** The [full voice catalog](https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md) covers English, Spanish, French, German, Italian, Dutch, Portuguese, Russian, Polish, Turkish, Chinese, Arabic, Hindi, and more — each with `x_low` / `low` / `medium` / `high` quality tiers. Sample voices at [rhasspy.github.io/piper-samples](https://rhasspy.github.io/piper-samples/).\n+\n+**Using a pre-downloaded voice.** Set `tts.piper.voice` to an absolute path ending in `.onnx`:\n+\n+```yaml\n+tts:\n+  piper:\n+    voice: /path/to/my-custom-voice.onnx\n+```\n+\n+**Advanced knobs** (`tts.piper.length_scale` / `noise_scale` / `noise_w_scale` / `volume` / `normalize_audio`, `use_cuda`) correspond 1:1 to Piper's `SynthesisConfig`. They're ignored on older `piper-tts` versions.\n+\n ### Custom command providers\n \n-If a TTS engine you want isn't natively supported (Piper, VoxCPM, MLX-Kokoro, XTTS CLI, a voice-cloning script, anything else that exposes a CLI), you can wire it in as a **command-type provider** without writing any Python. Hermes writes the input text to a temp UTF-8 file, runs your shell command, and reads the audio file the command produced.\n+If a TTS engine you want isn't natively supported (VoxCPM, MLX-Kokoro, XTTS CLI, a voice-cloning script, anything else that exposes a CLI), you can wire it in as a **command-type provider** without writing any Python. Hermes writes the input text to a temp UTF-8 file, runs your shell command, and reads the audio file the command produced.\n \n Declare one or more providers under `tts.providers.<name>` and switch between them with `tts.provider: <name>` — the same way you switch between built-ins like `edge` and `openai`.\n \n ```yaml\n tts:\n-  provider: piper-en               # pick any name under tts.providers\n+  provider: voxcpm                 # pick any name under tts.providers\n   providers:\n-    piper-en:\n-      type: command\n-      command: \"piper -m ~/models/en_US-amy.onnx -f {output_path} < {input_path}\"\n-      output_format: wav\n-\n     voxcpm:\n       type: command\n       command: \"voxcpm --ref ~/voice.wav --text-file {input_path} --out {output_path}\"","@@ -143,6 +178,11 @@ tts:\n       command: \"python -m mlx_kokoro --in {input_path} --out {output_path} --voice {voice}\"\n       voice: af_sky\n       output_format: wav\n+\n+    piper-custom:                  # native Piper also supports custom .onnx via tts.piper.voice\n+      type: command\n+      command: \"piper -m /path/to/custom.onnx -f {output_path} < {input_path}\"\n+      output_format: wav\n ```\n \n #### Placeholders\n"]}]},{"kind":"pr","number":53560,"title":"feat: add Piper TTS provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17885","number":17885},{"kind":"pr","value":"#8508","number":8508},{"kind":"pr","value":"#17885","number":17885}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}