hermes-openrouter-voice
A Hermes Agent plugin that adds OpenRouter to both voice directions:
| Direction | Config | What it does |
|---|---|---|
| Speech-to-text | stt.provider: openrouter |
POST /api/v1/audio/transcriptions — 20-model catalog, json/verbose_json |
| Text-to-speech | tts.provider: openrouter |
POST /api/v1/audio/speech — vendor-prefixed speech slugs, model-specific voices |
Both reuse OPENROUTER_API_KEY — the same key your chat provider already uses — so there's no second
account, key or billing setup. Stdlib only: no pip dependencies.
It exists because upstream closed the "built-in OpenRouter STT provider" request as not-planned
(#24415) in favour of the provider
surfaces — plugins and stt.providers.<name> command providers. This is that path, packaged.
Install
A. Via Hermes (git under the hood)
hermes plugins install esketids/hermes-openrouter-voice
hermes plugins enable openrouter-voice
A full git URL works too: hermes plugins install https://github.com/esketids/hermes-openrouter-voice.git.
Later: hermes plugins update openrouter-voice.
Per profile — each profile has its own config.yaml and plugins/, so repeat with the profile flag:
hermes --profile <name> plugins install esketids/hermes-openrouter-voice
hermes --profile <name> plugins enable openrouter-voice
A profile may already have a standalone directory plugin elsewhere; symlinking keeps one source of truth:
ln -s ~/.hermes/plugins/openrouter-voice ~/.hermes/profiles/<name>/plugins/openrouter-voice
B. Manual clone
git clone https://github.com/esketids/hermes-openrouter-voice.git \
~/.hermes/plugins/openrouter-voice
Then enable it in ~/.hermes/config.yaml (or hermes plugins enable openrouter-voice):
plugins:
enabled:
- openrouter-voice
Configure
Minimum — pick the provider:
hermes config set stt.provider openrouter
hermes config set tts.provider openrouter # only if you want OpenRouter speech output
Model / voice / language (all optional; defaults shown):
stt:
provider: openrouter
language: en
openrouter:
model: openai/whisper-large-v3 # or meta/muse-voice-transcribe-1.0, google/chirp-3, …
language: "" # "" = auto-detect
tts:
provider: openrouter
openrouter:
model: deepgram/aura-2
voice: aura-2-thalia-en # voices are MODEL-SPECIFIC
Make both names selectable in the desktop GUI
Hermes' desktop Voice tab builds its provider dropdown from a bundle-compiled list plus any
command provider declared in config, and the served config schema never carries stt.provider /
tts.provider — so a plugin-only install serves at runtime but is not offered in that dropdown.
Declaring these two entries fixes that (they run the same code, via the shims in this repo):
# use the python that runs your Hermes (e.g. <repo>/venv/bin/python)
PY=~/.hermes/hermes-agent/venv/bin/python
PLUG=~/.hermes/plugins/openrouter-voice
hermes config set --force stt.providers.openrouter.type command
hermes config set --force stt.providers.openrouter.command \
"$PY $PLUG/transcribe.py --config ~/.hermes/config.yaml {input_path} {output_path} {language} {model}"
hermes config set --force tts.providers.openrouter.type command
hermes config set --force tts.providers.openrouter.command \
"$PY $PLUG/speak.py --config ~/.hermes/config.yaml {input_path} {output_path} {voice} {model}"
hermes config set --force tts.providers.openrouter.voice_compatible true
The model + voice rows (optional patch)
The provider names above appear in the dropdowns from config alone. The model row — the field with the 20-slug dropdown — is a different story, and it is worth knowing why before you file a bug against this plugin:
// apps/desktop/src/app/settings/constants.ts
// The curated voice keys (Settings → Voice) are the single source of which
// per-provider fields exist; both the Voice settings page and the
// Capabilities TTS panel derive from it so the two surfaces never drift.
const VOICE_KEYS = SECTIONS.find(s => s.id === 'voice')?.keys ?? []
That list is compiled into the desktop bundle, so neither a plugin nor a config entry can add a row to
it — there is no dynamic, server-driven path for these fields, and the desktop plugin SDK has no
settings area to contribute one through (src/contrib/types.ts areas: layouts, panes, routes,
statusBar, titleBar). The rows therefore ship as a patch against the desktop checkout — not in this
repo's tree. A catalog entry may not carry an app patch as a distribution channel: several listed
plugins patching apps/desktop would collide, and every core release would invalidate them.
Where it lives instead: branch gui-rows of this repo, and a copy on each machine that uses it at
~/.hermes/patches/openrouter-voice-gui/ (the patch plus the idempotent applier). Besides the desktop
files the patch seeds voice.mic_device_id / voice.speaker_device_id in hermes_cli/config_defaults.py:
sectionFieldEntries drops a row whose key is in neither the served schema nor the config, so a picker
listed in SECTIONS alone would never appear. The durable route
upstream is a PR against apps/desktop. Besides the rows themselves the patch adds Preview buttons
on the voice and speed rows, and makes the voice dropdown follow the selected model, mirroring how
the desktop already narrows OpenAI's voices.
Microphone and speaker pickers
Two more rows come with the patch, under the voice-capture keys:
| Row | Config key | What it does |
|---|---|---|
| Microphone | voice.mic_device_id |
pins getUserMedia to that device, in both the recorder and the barge-in analyser |
| Speaker | voice.speaker_device_id |
routes playback through setSinkId, for the client-direct, relay and data-URL paths alike |
"System default" (the empty value) is the shipped behaviour, so leaving them alone changes nothing. Two notes worth knowing:
- These are browser device ids from
navigator.mediaDevices.enumerateDevices(), which is a different namespace fromwake_word.input_device— that one is a PortAudio index/name used by the Python side for wake-word capture. Setting one does not set the other. - A configured device that is no longer connected raises
OverconstrainedError; recording then falls back to the system default and the row says so, rather than silently switching. The device list re-enumerates ondevicechange, so plugging in a headset refreshes it without a reload.
The Preview button speaks a sample using the settings in force at that moment (model + voice + speed) through the app's own playback ladder — client-direct synthesis where the profile has client-callable credentials, otherwise the gateway relay, which is the path that runs this plugin server-side and so applies the measured speed handling (native parameter inside a model's range, local time-stretch outside it). It drops the 60-second voice-config cache first, so a model/voice/speed you just typed is what you hear.
~/.hermes/patches/openrouter-voice-gui/apply-gui-rows.sh # patch + repack
~/.hermes/patches/openrouter-voice-gui/apply-gui-rows.sh --no-pack # patch only
Two caveats, both real:
- It edits
apps/desktop/src/app/settings/constants.tsin your Hermes checkout and runsnpm run pack(~5 min), which replaces the bundle under the running app — reload with ⌘R afterwards. hermes updatechecks outmainand rebuilds the desktop, which removes the row again. Re-run the script after an update. The upstream fix is the schema+desktop change in #112122 (STT) and #112126 (TTS).
Two things worth knowing about those commands:
--forceis required. Without it Hermes prints "use --force to write this path anyway" and writes nothing for these dotted registry keys.--configis not optional in practice: profiles do not always differ byHERMES_HOME(the desktop passes--profile <name>), so the shim must be told which config to read or it may read another profile's model.
Resolution order at dispatch is built-in → command provider → plugin, so the shims win when declared and the plugin is the fallback when they are not.
Verify
# any audio file; the shim prints the transcript
$PY ~/.hermes/plugins/openrouter-voice/transcribe.py --config ~/.hermes/config.yaml /path/to/clip.wav
# speech out
echo "round trip test" > /tmp/t.txt
$PY ~/.hermes/plugins/openrouter-voice/speak.py --config ~/.hermes/config.yaml /tmp/t.txt /tmp/out.mp3
Models and voices
python models.py # the shipped catalogs (no network)
python models.py --voices # every model's voice set
python models.py --voices hexgrad/kokoro-82m
python models.py --live # diff the shipped lists against the API
| Direction | Models shipped | Where the list comes from |
|---|---|---|
stt.openrouter.model |
21 transcription models | GET /api/v1/models?output_modalities=transcription |
tts.openrouter.model |
18 speech models | GET /api/v1/models?output_modalities=speech |
tts.openrouter.voice |
361 voices across 16 models | each model's supported_voices |
Voices are model-specific — the endpoint rejects a mismatched pair with a 400 that names the model, so the model field decides the valid voices:
deepgram/aura-2→ 90 voices (aura-2-thalia-en, …)hexgrad/kokoro-82m→ 54 (af_heart,af_bella, …) — note this model emits only the first sentence of longer inputminimax/speech-2.8-*→ 45,mistralai/voxtral-mini-tts-2603→ 30 (en_paul_neutral, …)microsoft/mai-voice-2→ 4 (en-US-Harper:MAI-Voice-2, …)fish-audio/*→ none published; those models speak with the provider's default voice, so leave the field empty (the API accepts the request withoutvoice)
There is no voices endpoint — the sets above come from supported_voices on the models API. With the
GUI patch applied, the voice dropdown follows the selected model instead of offering every voice.
Playback speed
tts.openrouter.speed (or the global tts.speed) takes 0.25-4.0 and is honoured on every model:
| Model | How the rate is applied |
|---|---|
deepgram/aura-2, minimax/*, microsoft/mai-voice-2, hexgrad/kokoro-82m |
natively, within the range the provider accepts (aura-2: 0.7-1.5 only — 1.6 is a 400) |
qwen/* |
never sent — it rejects the parameter with HTTP 400 |
voxtral, orpheus, grok, fish-audio, anything unmeasured |
local pitch-preserving time-stretch (ffmpeg atempo) |
| any rate outside a model's native range | nearest in-range value to the API + the remainder stretched locally |
Measured 2026-09-17: with speed=1.5 the audio landed at 0.60-0.68 of the baseline wherever the API
honours the parameter. Expect ±10-20 %: providers pace themselves differently between runs (two
identical aura-2 calls differed by 12 %), and a slow rate at a range edge can overshoot. The local
path needs ffmpeg (brew install ffmpeg); without it the rate is sent to the API only.
Voice samples
The API publishes no preview URLs, so samples are generated (and cached) locally:
python models.py --sample microsoft/mai-voice-2 # one clip per voice
python models.py --sample deepgram/aura-2 --montage # + a single concatenated file
python models.py --sample hexgrad/kokoro-82m af_heart --force
Each clip speaks its own voice id first ("<voice>. This is a sample of my voice.", override with
--phrase), which makes a montage self-labelling. Output lands in
~/.hermes/cache/openrouter-voice-samples/<model>/, and existing clips are reused unless --force.
What the endpoint actually accepts (measured, not assumed)
These cost real debugging time, so they are baked in:
| Finding | Detail |
|---|---|
response_format |
transcription accepts only json / verbose_json; "text" is a 400 |
| Sample rate | meta/muse-voice-transcribe-1.0 requires 16 kHz or 24 kHz mono WAV; a 22.05 kHz file is a live 400 (received 22050 Hz). PCM WAV is normalised in pure Python — no ffmpeg needed |
| Containers | 19 of 20 transcription models accept MP3/WebM; the desktop records audio/webm;codecs=opus, which needs ffmpeg (brew install ffmpeg) to reach a WAV-only model |
| Truncation | hexgrad/kokoro-82m returns only the first sentence (identical ~1.5 s clip for very different inputs) — not the default |
| Format | google/gemini-3.1-flash-tts-preview rejects mp3 and demands pcm, so this plugin always requests mp3 |
| Voices | model-specific: aura-2-* for deepgram/aura-2, af_heart for Kokoro, flux-*-en for Flux |
| Verified default | deepgram/aura-2 + aura-2-thalia-en — speaks multi-sentence input in full (verified 1.47 s vs 8.91 s for short/long input) |
Layout
plugin.yaml manifest (name, requires_env, tags)
__init__.py register(ctx) → registers BOTH providers
common.py credentials, profile-aware config, WAV normalise/resample, HTTP
stt_core.py transcription logic (no Hermes imports)
stt.py TranscriptionProvider wrapper
tts_core.py synthesis logic (no Hermes imports)
tts.py TTSProvider wrapper (raises on failure, voice_compatible)
transcribe.py STT command-provider shim (GUI selectability)
speak.py TTS command-provider shim (GUI selectability)
models.py catalog/voice listing (`--voices`, `--live`)
tests/ stdlib unit tests for the audio normaliser
Tests
python -m pytest tests/ -q # audio normaliser: downmix, resample, idempotence
The *_core.py modules deliberately import nothing from Hermes: the shims run outside the repo's
sys.path, so the ABC subclasses live in stt.py / tts.py and the logic is shared.
Related upstream work
- #24415 — "Add OpenRouter as an STT provider" (closed, not-planned; this is the sanctioned alternative)
- #15726 — "Add OpenRouter as a TTS provider" (open)
- PRs #112122 (STT built-in) and #112126 (TTS built-in) — the in-tree variants, if upstream ever prefers built-ins; the schema/desktop rows they add are what would put the OpenRouter model row inside the Voice tab
Licence
MIT