WIP: Multilingual Piper TTS: en=amy, ru=irina, es=claude (supersedes #24) #26
Closed
hermes-automation
wants to merge 1 commits from
hermes/tts-voice-multilingual into main
pull from: hermes/tts-voice-multilingual
merge into: titan:main
titan:main
titan:feature/hermes-agent-build-mirrors
titan:feature/hermes-agent-multiarch
titan:feature/hermes-next-hux
titan:feature/hermes-hux-contracts
titan:feature/t_39cf1905-webui-build-token
titan:feature/t_26da4c88-titan-capacity-guardrails-v4
titan:feature/t_26da4c88-titan-capacity-guardrails-v3
titan:feature/t_26da4c88-titan-capacity-guardrails-v2
titan:feature/t_26da4c88-titan-capacity-guardrails
titan:fix/t_39cf1905-jenkins-controller-priority
titan:feature/t_8cbe6a55-hermes-webui-release-v2
titan:feature/t_8cbe6a55-hermes-webui-release
titan:feature/hermes-domain-rename-agent-worker-v2
titan:hermes/t_65356568-multiuser-capacity-assessment
titan:fix/cli-auto-failover-effort
titan:feature/hermes-pr44-after-pr43
titan:fix/hermes-heartbeat-dead-reclaim-replacement
titan:handoff/t_1a2c627b
titan:feature/hermes-combined-multilingual-voice-v2
titan:fix/hermes-handsfree-stt-webm
titan:fix/hermes-restore-legacy-chat-triage-hosts-v2
titan:feature/hermes-domain-rename-agent-worker-bstein
titan:fix/hermes-restore-legacy-chat-triage-hosts-v3
titan:hermes/tts-voice-multilingual-prerequisite
titan:fix/hermes-restore-legacy-chat-triage-hosts
titan:fix/hermes-oidc-ensure-rerun-hostname-rollout
titan:feature/hermes-direct-cli-lane-concurrency-2-replacement
titan:feature/hermes-domain-rename-chat-bstein-triage
titan:feature/hermes-kanban-profile-concurrency-2
titan:feature/hermes-zombie-lane-heartbeat-reclaim
titan:hermes/repair-pr26-voice-split
titan:feature/hermes-durable-two-worker-cli-concurrency
titan:hermes/chat-voice-conversation-instrument
titan:feature/hermes-domain-rename-chat-triage
titan:hermes/voice-stt-tts-language-routing
titan:hermes/voice-stt-language-routing
titan:fix/hermes-chat-session-continuity-contract
titan:hermes/tts-voice-hfc-female
titan:hermes/fix-grafana-no-data-vm-storage
titan:feature/ci-gitops
titan:fix/jenkins-quality-gate-discovery
titan:feature/hermes-inpod-supervisor
titan:feature/hermes-chat-smoothness
titan:wt/t_1e95ea6d
titan:feature/hermes-full-handoff-acceptance
titan:feature/hermes-distributed-worker-pool
titan:feature/hermes-review-goal-semantics
titan:feature/hermes-cli-auto-quota-routing
titan:fix/hermes-result-decomposition-reliability
titan:feature/hermes-safe-gitea-pr-client
titan:feature/hermes-unprivileged-image-builder
titan:feature/hermes-cli-process-reaping
titan:feature/hermes-three-lane-placement
titan:wt/t_cca008de
titan:codex/cassandra-generator-08105
titan:cassandra-0851-rollout
titan:cassandra-0821-deploy
titan:codex/testing-dashboard-health-20260604
titan:feature/ariadne
titan:feature/pi-usb-scratch-rollout
titan:feature/sso-hardening
titan:codex/titan-iac-work-quality-gate
titan:codex/titan-iac-main-platform-metrics
titan:codex/titan-iac-platform-metrics
titan:feature/atlas-jobs-schedule-observability
titan:lane2/jenkins-cleanup-activate
titan:lane2/jenkins-cleanup-wiring-split
titan:codex-ananke-fixes
titan:feature/atlasbot-ananke-recovery
titan:feature/atlasbot
titan:deploy
titan:feature/vault-consumption
titan:feature/postgres-migration
titan:feature/bstein-dev-home
titan:feature/mailu
titan:feature/sso
titan:feature/atlas-monitoring
titan:restructure/hybrid-clusters
titan:fea/titan24-gpu
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
No description provided.
Delete Branch "hermes/tts-voice-multilingual"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Supersedes draft PR #24
Draft PR #24 (
hermes/tts-voice-hfc-female, from task t_74b76353) implementeden_US-hfc_female-mediumas the single default voice withen_US-amy-mediumbaked as an unused alternate. Brad changed the decision after that task
landed: the approved policy is now multilingual, not a single feminine
default. This PR starts fresh from
origin/main(does not build onhermes/tts-voice-hfc-female) and implements the new policy end to end.Recommend closing #24 without merging — it does not reflect the current
decision. I have not closed or merged anything myself.
Approved voice mapping (Brad, 2026-08-20)
en_US-amy-mediumru_RU-irina-mediumes_MX-claude-highhighquality. Piper only publishesclaudeunderes/es_MX/claude/high/— there is noes_ES-claude. I did not invent one. If Brad wants a Castilian option later, that would need a different upstream voice (e.g.es_ES-davefxor similar) added as a fourth mapping.All three model identifiers and checksums were verified against the same
pinned
rhasspy/piper-voicesrevision already used in this Dockerfile(
ea046e8458f6acd997706d6e6066a022b42f6fb1) by downloading each file freshand computing sha256 myself immediately before committing:
en_US-amy-medium.onnx—b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18en_US-amy-medium.onnx.json—95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77ru_RU-irina-medium.onnx—8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaaru_RU-irina-medium.onnx.json—c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7es_MX-claude-high.onnx—3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0es_MX-claude-high.onnx.json—1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12dI also actually loaded all three with
onnxruntime+piper-tts==1.5.0in athrowaway venv on this box and synthesized real audio for each (Cyrillic
text through Irina, Spanish text through claude, English through amy) —
this is not just "the checksum matches", the models genuinely work.
Server-side language routing (the hard part)
dockerfiles/hermes-jetson-tts-server.pypreviously read onlyinput/textand
speedand loaded exactly one voice at startup fromHERMES_TTS_VOICE.The
"voice"field the WebUI sent was already ignored. Baking three modelsin without a selection mechanism would not have been sufficient.
Implemented: a fixed
LANGUAGE_VOICE_MAPdict is the only path from aclient string to a model name:
en,en-US(case-insensitive,_/-both accepted) → amyru,ru-RU→ irinaes,es-MX,es-ES→ claudeerror, never an unloaded model)
"voice"field is never read by the handler — itcannot override policy, and no client string is ever used to build a
Path.resolve_voice_name()always returns a member of the fixedthree-name set, verified by property-based tests against a battery of
hostile inputs (path traversal, NUL bytes, wrong types, etc).
Verified live end-to-end with the real downloaded models (not mocked):
curlwith{"language":"ru-RU","input":"Привет, как дела?"}returns 200with
X-TTS-Voice: ru_RU-irina-medium;{"language":"en","voice":"ru_RU-irina-medium"}still returns amy, proving the
voicefield cannot override the policy.Preload vs. lazy-load (measured, not guessed)
Chose eager preload of all three voices at process start. Measured RSS
on this hardware with the real models: ~88MB for one voice loaded, ~243MB
for all three (so roughly +155MB versus the previous single-voice
baseline). That comfortably fits the pod's memory budget. Preload also keeps
the fail-closed missing-model check at process start (crash-loop is visible
immediately in
kubectl get pods) rather than deferring a possible failureto a live user's first request in a given language, and avoids
request-serializing a cold model load behind
VOICE_LOCKon first use.Bumped
services/hermes/voice-deployment.yamlmemory request from512Mi → 768Mi to cover the measured increase with headroom; the 2Gi limit
already had ample slack and is unchanged.
Is the WebUI actually going to speak Russian in Irina automatically? No — and here's why
I traced
dockerfiles/hermes-webui-atlas-patch.pyanddockerfiles/hermes-webui-atlas-voice.jsend to end. The WebUI has nosignal for the language of the assistant reply it is about to speak.
hermes-webui-atlas-voice.js'sfetchSpeech()posts only{text: chunk, engine: 'atlas'}to/api/tts; the patchedroutes.pyAtlas branch previously hardcoded a stale
"voice": "en_US-lessac-high"value that was already dead (ignored server-side) — I removed it rather than
leave a misleading value, since it never did anything. There is no
i18n/locale detection anywhere in these files or in
Dockerfile.hermes-webui.Net effect today: every reply, regardless of its actual language, speaks
in the safe English default (amy). Non-English replies will not
automatically get the right voice yet. The server-side plumbing is fully
functional and tested (see above) for whenever a caller does send
language— it's specifically the signal that's missing, not therouting.
To close this gap: something upstream of the TTS call needs to know/declare
the reply's language and pass it through
hermes-webui-atlas-voice.js→api/routes.py's atlas branch → thelanguagefield in the JSON POST tohermes-tts. That could be: taggingeach assistant turn server-side with a detected/declared language (e.g. from
the model's own response metadata or a lightweight langid pass) and
threading it through, or a user-facing language preference. I did not
implement any of that — the task was explicit that inventing detection here
would be worse than stating the gap plainly. Documented in
services/hermes/NOTES.mdunder "Private Jetson voice: multilingual TTSpolicy" as well.
Every site naming the voice (grepped
lessac,hfc_female,HERMES_TTS_VOICE)dockerfiles/Dockerfile.hermes-jetson-tts— added 6 new checksummedADDinstructions (3 voices × onnx+json) from the same pinned revision;extended the build-time
PiperVoice.loadassertion to load all 6 lessacENV HERMES_TTS_VOICE=en_US-amy-medium. Lessac modelskept (not removed) — see image size delta below.
dockerfiles/hermes-jetson-tts-server.py—DEFAULT_VOICE_NAMEdefaultnow
en_US-amy-medium, aligned with the Dockerfile ENV.services/hermes/voice-deployment.yaml—HERMES_TTS_VOICE→en_US-amy-medium;ai.bstein.dev/modelannotation changed from namingonly the single lessac voice to a
piper-multilingual-prefix listing allthree baked voices (
en_US-amy-medium,ru_RU-irina-medium,es_MX-claude-high), so it is now truthful for a three-voice deployment;memory request bumped 512Mi → 768Mi.
dockerfiles/hermes-webui-atlas-patch.py— removed the dead hardcoded"voice"field from the Atlas TTS request body (see above); itsfail-closed
replace_exactpatching property is untouched everywhereelse in the file.
services/hermes/NOTES.md— did not previously mention TTS/Piper/voiceat all; added a new "Private Jetson voice: multilingual TTS policy"
section documenting the mapping and the WebUI language-signal gap.
services/comms/knowledge/catalog/atlas.{json,yaml}— generated files(do not edit by hand); they reference the
hermes-ttsimage only bydigest, which this PR does not change, so nothing to regenerate here.
hfc_female— zero hits anywhere in the repo; this branch was builtfresh from
origin/main, never fromhermes/tts-voice-hfc-female.Image size delta
Kept the lessac models (high/medium/low, 6 files) per the task instructions
rather than removing them. Added exactly the 3 new voices (6 files):
+189,539,507 bytes (~180.8 MiB) measured from the real downloaded files
(amy 63,201,294B + json 4,882B; irina 63,201,294B + json 4,765B; claude-high
63,122,309B + json 4,963B). If Brad wants to trim image size later, the
lessac set is the obvious removal candidate since nothing in the language
policy routes to it anymore.
Rollout-coupling hazard — read before merging
hermes-jetson-tts-server.pyfails closed at startup ifHERMES_TTS_VOICEdoesn't name one of the three baked voices, and every baked voice must
exist on disk or
load_voices()raises immediately.voice-deployment.yaml:126pins the
hermes-ttsimage by digest with no Flux image automationfor hermes. The env/manifest change in this PR and the new image must
roll out together, or image-first — if the manifest's env/annotation
changes land before a new image exists with the new models baked in, the
pod crash-loops (old image, new
HERMES_TTS_VOICE=en_US-amy-medium, modelnot present because the old image only has lessac).
Required post-merge sequence:
hermes-agent-image-equivalent Jenkins build forDockerfile.hermes-jetson-ttsoff the mergedmaincommit.voice-deployment.yaml:126to thenewly built image (no Flux image automation will do this for you).
hermes-ttspod restarts cleanly ontitan-21(watchstartupProbe— 60 × 5s = 300s budget, comfortably covers the ~15-20sthree-voice load I measured locally) and
/healthreports all threevoice names.
Tests (written before the implementation)
New
testing/tests/test_hermes_tts_language_routing.py(42 cases): everyallow-listed language variant (case/separator combinations) → correct
voice; unknown/missing/malformed language (wrong types, empty, path
traversal strings, NUL bytes) → amy fallback; client
voicefield provennot to override
language-driven policy;resolve_voice_name()proven toalways return one of the three baked names for a battery of hostile inputs;
load_voices()fails closed when a baked model file is missing.Updated
testing/tests/test_hermes_chat_quality.py: new checksums, voiceenv, annotation, and Dockerfile assertions.
Full suite:
pytest testing/→ 2575 passed, 4 skipped (no clusteraccess / no
visudoin this sandbox), 8 failed — all 8 confirmed tofail identically on a clean, unmodified checkout of
origin/mainin athrowaway worktree before I touched anything (4 of these match the task's
documented pre-existing list; the other 4 —
test_atlas_ai_dashboard.pyx2,test_hermes_ai_usage_exporter_coverage.py,test_hermes_execution_pool_dispatch_v2.py— are pre-existing but weren'tin that list, likely drift since it was written; none touch TTS/voice code
and none are introduced by this diff).
kustomize build services/hermesrenders cleanly with the new env/annotation values present in the output.
Files changed
dockerfiles/Dockerfile.hermes-jetson-ttsdockerfiles/hermes-jetson-tts-server.pydockerfiles/hermes-webui-atlas-patch.pyservices/hermes/NOTES.mdservices/hermes/voice-deployment.yamltesting/tests/test_hermes_chat_quality.pytesting/tests/test_hermes_tts_language_routing.py(new)Pull request closed