Hermes Voice Diagnostics (STT/TTS)
IDENTITY: VoicePipelineVerifier. Prove each layer with a real test, never guess. WHENUSE: "STT/voice doesn't work", "Hermes can't hear me", "do my subs cover STT/TTS?", "voice mode does nothing", mic/headset questions for Hermes. LAW: DiagnoseBottomUp{MicHardware→AudioDeps→ProviderResolution→ProductionPath→UIMode}. Each layer has a one-command proof — run them in order, stop at the first failure.
The 5 layers (each with a definitive test)
Run from the Hermes venv: cd ~/.hermes/hermes-agent and use ./venv/bin/python3.
1. Mic hardware + permission (macOS TCC)
./venv/bin/python3 -c "
import sounddevice as sd, numpy as np
print(sd.query_devices(kind='input'))
rec = sd.rec(int(3*16000), samplerate=16000, channels=1, dtype='float32'); sd.wait()
peak = float(np.max(np.abs(rec)))
print('VERDICT:', 'SIGNAL PRESENT' if peak > 0.001 else 'SILENCE — permission/device issue')"
Signal present = the terminal app has mic permission and the device works. No headset needed if this passes.
2. Audio deps
sounddevice + numpy must be importable in the venv. pyaudio/soundfile/scipy are NOT required.
3. Provider resolution (the layer that lies)
./venv/bin/python3 -c "
import sys; sys.path.insert(0,'.')
from hermes_cli.config import load_config
from tools.transcription_tools import is_stt_enabled, _get_provider
cfg = load_config().get('stt', {})
print('cfg:', cfg); print('enabled:', is_stt_enabled(cfg)); print('provider:', _get_provider(cfg))"
stt.enabledmay be absent from a rawgrep '^stt:' config.yamlyet still True — defaults merge at load. Trustload_config(), not grep.- Config
provider: openaiworks with no key at all when the Nous subscription managed gateway is active:_resolve_openai_audio_client_config()falls throughstt.openai.api_key→VOICE_TOOLS_OPENAI_KEY/OPENAI_API_KEY→resolve_managed_tool_gateway("openai-audio"). A commented-outVOICE_TOOLS_OPENAI_KEYis no longer fatal — this was the historical STT breakage and is now covered by the sub.
4. Production-path self-test WITHOUT a human speaker
Play TTS audio out loud and record it through the mic — proves mic → hermes_cli.voice → STT in one shot:
(say "Hermes voice pipeline full stack test." &) && sleep 0.3 && ./venv/bin/python3 -c "
import sys, time; sys.path.insert(0,'.')
import hermes_cli.voice as v
v.start_recording(); time.sleep(4)
print('TRANSCRIPT:', repr(v.stop_and_transcribe()))"
Minor mishears (e.g. "Burmese" for "Hermes") are Whisper quality artifacts over speaker→mic, not bugs.
Requirements probe (same one /voice status uses): from tools.voice_mode import check_voice_requirements.
5. TUI voice-mode UX gotchas (most common "bug" report)
/voice onenables the MODE only — it does NOT record. Press Ctrl+B (configvoice.record_key) to capture; VAD auto-stops on silence.- Voice mode is runtime-only (
HERMES_VOICEenv): every TUI launch starts OFF by design. No persisted toggle exists. /voice tts= replies spoken, you still type./voice on+ Ctrl+B = bidirectional.
TTS verification
./venv/bin/python3 -c "
import sys; sys.path.insert(0,'.')
from tools.tts_tool import text_to_speech_tool
print(text_to_speech_tool('TTS pipeline test.'))"
success: true + an mp3 path under profiles/<name>/cache/audio/ = TTS chain works (config tts.provider: openai resolves through the same managed gateway as STT).
Coverage matrix (check before recommending new keys/subs)
| Path | Requirement | Cost |
|---|---|---|
| STT via Nous managed gateway (OpenAI Whisper) | Nous subscription | included |
| STT via Groq Whisper | GROQ_API_KEY |
free tier |
| STT local faster-whisper | pip install faster-whisper in venv |
free |
TTS via Nous/OpenAI (gpt-4o-mini-tts) |
Nous sub or OPENAI_API_KEY |
included/paid |
| TTS Edge (default) | none | free |
| TTS via Fish Audio (command provider, senna) | FISH_API_KEY in profile .env |
free tier (s2.1-pro-free) |
Provider scope (verified 2026-07-27 against tools/transcription_tools.py + tools/tts_tool.py):
- STT built-ins: openai, groq, local (faster-whisper), local_command, mistral, xai, elevenlabs, deepinfra
- TTS built-ins: edge (default), openai, piper, kittentts, neutts, elevenlabs, minimax, xai, mistral, gemini, deepinfra
- Groq is STT-ONLY in Hermes — no groq TTS provider exists despite Groq's PlayAI TTS models. Users routinely assume Grok/Groq covers TTS; it does not.
- Intel Macs (no CoreML/ANE): local faster-whisper base/small and piper/kittentts TTS run fine on CPU (AVX2). See references/macos-dictation-options.md for the system-wide dictation-app landscape (most polished FOSS apps require Apple Silicon).
Adding a TTS provider outside the built-ins (command-type, no plugin)
Any tts.provider value NOT in BUILTIN_TTS_PROVIDERS resolves to tts.providers.<name> in config.yaml (tools/tts_tool.py). Cheapest way to add any HTTP TTS API — no plugin needed:
tts:
provider: fish # flip only after the provider block is verified
providers:
fish:
type: command
command: "python3 /path/to/tts_script.py {text_path} {output_path} {voice} {model}"
model: some-model
format: mp3
timeout: 60
env_passthrough: [FISH_API_KEY] # REQUIRED — see traps
Placeholders: {text_path} (alias {input_path}), {output_path}, {format}, {voice}, {model}, {speed} — auto shell-quoted for their position in the template. A plugin-registered TTSProvider (agent/tts_registry) is the upgrade path only when command shows a real ceiling (streaming, registry integration).
Traps (verified 2026-08-04, senna profile):
- Child env is scrubbed of Hermes secrets by default — the API key never reaches the command unless the block declares
env_passthroughas a REAL YAML list. hermes config setcannot write lists —set tts.providers.fish.env_passthrough '["FISH_API_KEY"]'lands as a quoted STRING, which the resolver silently treats as missing (key scrubbed → auth failure). Set scalars via CLI, then fix the list line with a surgical text edit.- patch/write_file tools refuse profile config.yaml ("security-sensitive").
hermes config setfor scalars; scripted line-level edit for YAML structures the CLI can't express. - Verify through the venv, not the file:
_resolve_command_provider_config('<name>', load_config()['tts'])+_render_command_tts_template(...)+_command_provider_env_passthrough(...)show exactly what will run and what env survives.
Worked example (Fish Audio, incl. model-as-header quirk and the no-OpenAI-compatible-endpoint finding): references/fish-audio-tts-provider.md. Ready-to-copy stdlib script: templates/fish_tts.py.
Logs for voice failures
profiles/<name>/logs/tui_gateway_crash.log (voice RPC crashes, PortAudio/Pa_Terminate shutdown noise is benign), agent.log, gateway.error.log. Note: SIGTERM-adjacent Pa_Terminate stack frames in the crash log are shutdown noise, not the voice bug — look for voice.toggle/voice.record warnings instead.
Wake word (openwakeword) lazy-dep failures
Symptom: every CLI launch prints Installing wake word engine... then Feature 'wake.openwakeword' unavailable: pip install failed. Root cause lives in tools/lazy_deps.py LAZY_DEPS["wake.openwakeword"] (pins: openwakeword, onnxruntime, sounddevice, numpy). Diagnosis pattern:
- Read the pin out of
LAZY_DEPSintools/lazy_deps.py. - Check the pinned version's wheel list for YOUR platform:
curl -s https://pypi.org/pypi/<pkg>/<ver>/json→urls[].filename. "No matching distribution" with a version list that stops below the pin = the package dropped wheels for your platform/arch after the last listed version. - Check upstream first:
cd ~/.hermes/hermes-agent && git fetch origin main && git log HEAD..origin/main -- tools/lazy_deps.py tools/wake_word.py. - Fixes: (a) make the pin platform-conditional in lazy_deps.py (one line; overwritten by
hermes updateuntil upstream fixes it — file an issue), or (b) if the user doesn't use voice activation,wake_word.enabled: falsein the profile config.yaml.
Key traps:
_is_satisfied()enforces the==pin strictly — manually installing a compatible older version does NOT silence the retry loop; the version mismatch re-triggers install every launch. The pin itself must change.- Installs run via
uv pip installinto the venv ofsys.executable(the.hermes-runtimepython), not the shell'spython3— check wheels for THAT interpreter's tag (e.g. cp311, not the system 3.14). - onnxruntime dropped Intel macOS (x86_64) wheels after 1.23.2 — 1.24.0+ is arm64-only. Full session detail: references/wake-word-intel-mac-pin.md.
Pitfalls
- Ctrl+B with zero feedback = check layer 2 first. Missing
sounddevice/numpyin the venv fails silently in the TUI — no error, no beep, nothing.check_voice_requirements()names the missing packages;pip install sounddevice numpyin the venv fixes it. Observed 2026-07-27 on senna profile. - Don't conclude "STT broken" from config alone. The backend can be 100% functional while the user just never pressed Ctrl+B — check layer 5 before touching config.
- grep config.yaml ≠ effective config. Defaults merge at load; use
load_config()in the venv. echo $GROQ_API_KEYfrom a subprocess proves nothing — agent subprocesses don't inherit the CLI's loaded.env. Test via the venv import path. 3b. Groq "Connection error" can mean invalid key._transcribe_groqreports a dead/revokedGROQ_API_KEYasConnection error, not a clean 401. Verify the key directly:curl -s https://api.groq.com/openai/v1/models -H "Authorization: Bearer $KEY"—invalid_api_key= get a fresh free key at console.groq.com. (Observed 2026-07-27: gsk_ key present in .env but revoked.) 3c. "Connection error" can also mean a mangledGROQ_BASE_URL. If the key validates via curl but the SDK still throwsAPIConnectionError, printtools.transcription_tools.GROQ_BASE_URL— it'sos.getenv("GROQ_BASE_URL", <correct default>)at import time, so a bad env line (observed 2026-07-27:https:https://api.groq.com/.../audio/transcriptions— doubled scheme + endpoint path the SDK appends itself) breaks every call while curl/httpx succeed. Delete the env line entirely (default is correct); the var must not include/audio/transcriptions. Remember the agent's own terminal shell snapshots env at session start — retest withenv -u GROQ_BASE_URLor a fresh shell before concluding the fix failed. Do NOT test keys with Pythonurllib— Cloudflare blocks its default UA with error 1010 even for valid keys; use curl or httpx. 3c. "Connection error" with a VALID key = checkGROQ_BASE_URL. The module default is correct (https://api.groq.com/openai/v1), but aGROQ_BASE_URLline in~/.hermes/.envor a profile.envoverrides it. A mangled value (observed 2026-07-27:https:https://api.groq.com/openai/v1/audio/transcriptions— doubled scheme AND the full endpoint path baked in) makes the SDK build an unparseable URL →APIConnectionError→ "Connection error". TELL: curl and raw httpx GETs to api.groq.com succeed (they never touch base_url) while only the SDK path fails. Diagnose by printingtt.GROQ_BASE_URLfrom the venv — don't trust the source default, env wins. FIX: delete the env line entirely (code default takes over). It propagates across profile .env files — grep all of them:grep -rln GROQ_BASE_URL ~/.hermes/profiles/*/.env. The SDK appends/audio/transcriptionsitself; a base_url must never include it.- Restart semantics:
stt.providerchanges need a full process restart (CLI relaunch / gateway/restart);/resetis not enough. - Session-history check first: if
session_searchshows zero/voiceattempts, the failure is at the UX layer (layer 5), not the backend.