mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-03 02:11:48 +00:00
Piper (OHF-Voice/piper1-gpl) is a fast, local neural TTS engine from the
Home Assistant project that supports 44 languages with zero API keys.
Adds it as a native built-in provider alongside edge/neutts/kittentts,
installable via 'hermes tools' with one keystroke.
What ships:
- New 'piper' built-in provider in tools/tts_tool.py
- Lazy import via _import_piper()
- Module-level voice cache keyed on (model_path, use_cuda) so switching
voices doesn't invalidate older cached voices
- _resolve_piper_voice_path() accepts either an absolute .onnx path or a
voice name (auto-downloaded on first use via 'python -m
piper.download_voices --download-dir <cache>')
- Voice cache at ~/.hermes/cache/piper-voices/ (profile-aware via
get_hermes_dir)
- Optional SynthesisConfig knobs: length_scale, noise_scale,
noise_w_scale, volume, normalize_audio, use_cuda — passed through
only when configured, so older piper-tts versions aren't broken
- WAV output then ffmpeg conversion path (same as neutts/kittentts) so
Telegram voice bubbles work when ffmpeg is present
- Piper added to BUILTIN_TTS_PROVIDERS so a user's
tts.providers.piper.command cannot shadow the native provider
(regression test included)
- 'hermes tools' wizard entry
- Piper appears under Voice and TTS as local free, with
'pip install piper-tts' auto-install via post_setup handler
- Prints voice-catalog URL and default-voice info after install
- config.yaml defaults
- tts.piper.voice defaults to en_US-lessac-medium
- Commented advanced knobs for discoverability
- Docs
- New 'Piper (local, 44 languages)' section in features/tts.md
explaining install path, voice switching, pre-downloaded voices,
and advanced knobs
- Piper listed in the ten-provider table and ffmpeg table
- Custom-command-providers section updated to drop the Piper example
(now native) and add a piper-custom example for users with their own
trained .onnx models
- overview.md bumps provider count to ten
- Tests (tests/tools/test_tts_piper.py, 16 tests)
- Registration (BUILTIN_TTS_PROVIDERS, PROVIDER_MAX_TEXT_LENGTH)
- _resolve_piper_voice_path across every branch: direct .onnx path,
cached voice name, fresh download with correct CLI args, download
failure, successful-exit-but-missing-files, empty voice to default
- _generate_piper_tts: loads voice once, reuses cache, voice-name
download wiring, advanced knobs flow through SynthesisConfig
- text_to_speech_tool end-to-end dispatch and missing-package error
- check_tts_requirements: piper availability toggles the return value
- Regression guard: piper cannot be shadowed by a command provider
with the same name
- Pre-existing test_tts_mistral test broadened to mock the new
piper/kittentts/command-provider checks (otherwise it false-passes
when piper is installed in the test venv)
E2E verification (live):
Actual pip install piper-tts, config piper + en_US-lessac-low,
text_to_speech_tool call, voice auto-downloaded from HuggingFace,
WAV synthesized, ffmpeg-converted to Ogg/Opus. Second call hits the
cache (~60ms). Cache dir populated with .onnx and .onnx.json.
This caught a real bug during development: the first pass used '-d' as
the download-dir flag; the actual piper.download_voices CLI wants
'--download-dir'. Fixed before PR opened.
This commit is contained in:
parent
2662bfb756
commit
8d302e37a8
8 changed files with 634 additions and 34 deletions
|
|
@ -81,29 +81,39 @@ class TestResolveCommandProviderConfig:
|
|||
def test_user_declared_command_provider_resolves(self):
|
||||
cfg = {
|
||||
"providers": {
|
||||
"piper": {"type": "command", "command": "piper foo"},
|
||||
"piper-cli": {"type": "command", "command": "piper-cli foo"},
|
||||
},
|
||||
}
|
||||
resolved = _resolve_command_provider_config("piper", cfg)
|
||||
resolved = _resolve_command_provider_config("piper-cli", cfg)
|
||||
assert resolved is not None
|
||||
assert resolved["command"] == "piper foo"
|
||||
assert resolved["command"] == "piper-cli foo"
|
||||
|
||||
def test_type_command_is_implied_when_command_is_set(self):
|
||||
cfg = {"providers": {"piper": {"command": "piper foo"}}}
|
||||
resolved = _resolve_command_provider_config("piper", cfg)
|
||||
cfg = {"providers": {"piper-cli": {"command": "piper-cli foo"}}}
|
||||
resolved = _resolve_command_provider_config("piper-cli", cfg)
|
||||
assert resolved is not None
|
||||
|
||||
def test_other_type_values_reject(self):
|
||||
cfg = {"providers": {"piper": {"type": "python", "command": "piper foo"}}}
|
||||
assert _resolve_command_provider_config("piper", cfg) is None
|
||||
cfg = {"providers": {"piper-cli": {"type": "python", "command": "piper-cli foo"}}}
|
||||
assert _resolve_command_provider_config("piper-cli", cfg) is None
|
||||
|
||||
def test_empty_command_rejects(self):
|
||||
cfg = {"providers": {"piper": {"type": "command", "command": " "}}}
|
||||
assert _resolve_command_provider_config("piper", cfg) is None
|
||||
cfg = {"providers": {"piper-cli": {"type": "command", "command": " "}}}
|
||||
assert _resolve_command_provider_config("piper-cli", cfg) is None
|
||||
|
||||
def test_case_insensitive_lookup(self):
|
||||
cfg = {"providers": {"piper": {"type": "command", "command": "x"}}}
|
||||
assert _resolve_command_provider_config("PIPER", cfg) is not None
|
||||
cfg = {"providers": {"piper-cli": {"type": "command", "command": "x"}}}
|
||||
assert _resolve_command_provider_config("PIPER-CLI", cfg) is not None
|
||||
|
||||
def test_native_piper_cannot_be_shadowed_by_command_entry(self):
|
||||
"""Regression guard for PR that added native Piper as a built-in.
|
||||
A user's ``tts.providers.piper`` must not override the built-in."""
|
||||
cfg = {
|
||||
"providers": {
|
||||
"piper": {"type": "command", "command": "some-script"},
|
||||
},
|
||||
}
|
||||
assert _resolve_command_provider_config("piper", cfg) is None
|
||||
|
||||
|
||||
class TestGetNamedProviderConfig:
|
||||
|
|
@ -145,16 +155,16 @@ class TestIterCommandProviders:
|
|||
cfg = {
|
||||
"providers": {
|
||||
"openai": {"type": "command", "command": "shouldnt show up"},
|
||||
"piper": {"type": "command", "command": "piper"},
|
||||
"piper-cli": {"type": "command", "command": "piper-cli"},
|
||||
"voxcpm": {"type": "command", "command": "voxcpm"},
|
||||
"broken": {"type": "command", "command": ""},
|
||||
},
|
||||
}
|
||||
names = sorted(name for name, _ in _iter_command_providers(cfg))
|
||||
assert names == ["piper", "voxcpm"]
|
||||
assert names == ["piper-cli", "voxcpm"]
|
||||
|
||||
def test_has_any_command_provider_detects_declared(self):
|
||||
cfg = {"providers": {"piper": {"type": "command", "command": "piper"}}}
|
||||
cfg = {"providers": {"piper-cli": {"type": "command", "command": "piper-cli"}}}
|
||||
assert _has_any_command_tts_provider(cfg) is True
|
||||
|
||||
def test_has_any_command_provider_when_none(self):
|
||||
|
|
@ -216,16 +226,16 @@ class TestConfigGetters:
|
|||
|
||||
class TestMaxTextLengthForCommandProviders:
|
||||
def test_default_for_command_provider(self):
|
||||
cfg = {"providers": {"piper": {"type": "command", "command": "x"}}}
|
||||
assert _resolve_max_text_length("piper", cfg) == DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH
|
||||
cfg = {"providers": {"piper-cli": {"type": "command", "command": "x"}}}
|
||||
assert _resolve_max_text_length("piper-cli", cfg) == DEFAULT_COMMAND_TTS_MAX_TEXT_LENGTH
|
||||
|
||||
def test_override_under_providers(self):
|
||||
cfg = {"providers": {"piper": {"type": "command", "command": "x", "max_text_length": 2500}}}
|
||||
assert _resolve_max_text_length("piper", cfg) == 2500
|
||||
cfg = {"providers": {"piper-cli": {"type": "command", "command": "x", "max_text_length": 2500}}}
|
||||
assert _resolve_max_text_length("piper-cli", cfg) == 2500
|
||||
|
||||
def test_override_under_legacy_tts_name_block(self):
|
||||
cfg = {"piper": {"type": "command", "command": "x", "max_text_length": 7777}}
|
||||
assert _resolve_max_text_length("piper", cfg) == 7777
|
||||
cfg = {"piper-cli": {"type": "command", "command": "x", "max_text_length": 7777}}
|
||||
assert _resolve_max_text_length("piper-cli", cfg) == 7777
|
||||
|
||||
def test_non_command_unknown_provider_still_falls_back(self):
|
||||
assert _resolve_max_text_length("unknown", {}) > 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue