WIP: Voice mode: speak replies in the language private Whisper STT detected (follows #26) #27
Closed
hermes-automation
wants to merge 1 commits from
hermes/voice-stt-tts-language-routing into main
pull from: hermes/voice-stt-tts-language-routing
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-language-routing
titan:hermes/tts-voice-multilingual
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/voice-stt-tts-language-routing"
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?
Follows PR #26 — merge this after it
This is a separate, independent branch cut fresh from
origin/mainat11bd04cce5d8861bb9d4865c580d77ffac48e218. It does not contain, amend,rebase onto, or merge any commit from PR #26 (
hermes/tts-voice-multilingual),and I have not touched, closed or merged that PR.
PR #26 bakes three Piper voices and adds the server-side
languageroutingthat picks between them (English to amy, Russian to irina, Spanish to claude,
everything else to amy). It states plainly that the WebUI never sends a
language, so in practice every reply is spoken by the English voice. ThisPR supplies that missing signal.
Merge order matters. Land #26 first, then this. If this lands alone the
change is inert but harmless: today's
hermes-ttsserver reads onlyinput/textandspeedfrom the request body, so the extralanguagefieldis ignored and everything still speaks English.
Note on a stale branch ref. An earlier ref
hermes/voice-stt-language-routingexists atfe45d23efrom a first push. TheSCM broker only permits ref creation — it rejects updates and deletions — so I
could not move it and could not remove it. That commit is missing the one-line
test_hermes_chat_quality.pyfix described under "Files changed", so it issuperseded and should be ignored. This PR's head branch is the one to review;
no PR was ever opened against the stale ref.
Expected merge conflict, and how to resolve it. Both PRs edit the same four
lines of the Atlas TTS request body in
dockerfiles/hermes-webui-atlas-patch.py.#26 deletes the dead hardcoded
"voice"line; this PR adds a"language"linenext to it and deliberately leaves
"voice"alone so its diff stays purelyadditive against
main. The resolution is to keep both changes — drop the"voice"entry, keep"language": _atlas_tts_language(data). A botchedresolution cannot ship silently: the patch is a fail-closed
replace_exactandDockerfile.hermes-webuigreps for the exact resulting text, so the image buildfails instead. A smaller conflict may also appear in
services/hermes/NOTES.mdif #26's new voice section lands adjacent to this one; both sections should be
kept.
What I actually traced first
Whether this needs a WebUI patch at all depends entirely on what
/api/transcribeand the local-command STT contract actually look like, so Itraced them before designing anything. It does need one.
I pulled the two pinned upstream images by digest and read the real code rather
than guessing:
ghcr.io/nesquena/hermes-webuiat digestsha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2(the digest
Dockerfile.hermes-webuipins).nousresearch/hermes-agentat digestsha256:47d4bd4cc420b70e40ed75efdade373e45b86b7382d4013a054208982bb6ba08(the digest
Dockerfile.hermes-agentpins). Itstools/transcription_tools.pyis byte-identical to the copy in this worker(
sha256:d7d7df56b98dfadc0a7c08d5addd02db4e3106e6fd3b753e9241c23145eb2951),and
Dockerfile.hermes-agentdoes not patch that file.What the path actually is:
atlas-voice.jsPOSTs the audio blob to/api/transcribe.handle_transcribeinapi/upload.py, notapi/routes.py. It callstools.transcription_tools.transcribe_audio(temp_path)and returnsexactly
{'ok': True, 'transcript': transcript}— the language isdropped even if the provider knew it.
stt.provider: local_command,transcribe_audiodispatches to_transcribe_local_command, whose contract with the shelled-out command is"leave a
.txtin--output-dir". It globs*.txt, reads the firstmatch, and returns
{success, transcript, provider}. There is no field inthat contract that can carry a language, which is why this could not be
done by changing only
hermes_stt_client.py.hermes_stt_client.pyposts to the Jetson Whisper service, which runsauto-detection (
stt.local.language: autoinchat-configmap.yaml) butreturned only
{text, model}.sendTranscriptputs the text in the composer and calls the app's ownwindow.send(). The reply is spoken later byspeakResponse, reachedthrough the
window.autoReadLastAssistantoverride — so the language has tosurvive from the transcription response to a callback that happens after a
full model turn, which is where all the staleness risk lives.
So: a narrow WebUI patch is required, and one narrow agent-side patch with it.
Both are confined to the WebUI image.
The signal path, and what enforces it at each hop
Every hop narrows the value; no hop widens it.
hermes-jetson-stt-server.pyresult["language"]only as a bare ISO-639 token (2–3 ASCII letters, case/whitespace normalised); anything else reported as emptyhermes_stt_client.py<stem>.languagesidecar; never a second.txt_transcribe_local_command*.language, re-validates the shape, addslanguageto its result dict/api/transcribehandle_transcribelanguagebesidetranscriptatlas-voice.jsen/ru/es, bound to the voice-mode generation token; consumed once/api/tts("en", "ru", "es"), everything else becomes EnglishThe TTS language is derived only from the server response representing the
STT model result. It is never taken from a
voicefield — the browser does notsend one and
_atlas_tts_language()only ever readsbody["language"]. Thebrowser-side filter is a UX narrowing, not the security control; the server-side
allow-list in
api/routes.pyis, and PR #26's Jetson-side allow-list is thefinal one.
Staleness, races and multi-turn
The language is bound to the voice-mode
generationtoken, which alreadyincrements on every activate/deactivate, and it rides alongside the existing
thinkingSessioncheck. It is read throughtakeSttLanguage(token), whichreturns it only for a matching token and clears it in the same call, so it can
be consumed at most once. It is also cleared explicitly on: deactivation, a new
activation, the start of every listening window, an empty transcript, a session
change between asking and answering, and a transcription error (which
deactivates). The failure direction is always "fall back to English", never
"reuse the previous turn's language".
Why the agent file is patched, and how narrowly
/opt/hermes/tools/transcription_tools.pyis patched fromDockerfile.hermes-webui, notDockerfile.hermes-agent. That confines it tothe WebUI image.
HERMES_LOCAL_STT_COMMANDis set on thewebuicontainer only(
chat-statefulset.yaml:353), so that container is both the only consumer ofthis path and the only one built from the patched image. The
hermescontaineris untouched. The edit is additive — one extra key in the returned dict and a
glob for a suffix nothing else writes — so an unpatched caller behaves exactly
as before.
What this does not claim
asked a Russian question may answer in English and will then be read by the
Russian voice, and the reverse. That is the deliberate policy for hands-free
mode, not a detection failure.
language from a transcript's characters.
produced by a spoken turn have no trusted STT signal and stay on the English
voice. The read-aloud path in
ui.jsis unchanged and still sends nolanguage; there is a test asserting that./api/transcribereports what Whisper detected, including languages with nobaked voice (French comes back as
fr). The browser and both server-sideboundaries then resolve it to English. I chose truthful reporting at the STT
endpoint and policy enforcement at the TTS boundary rather than blanking the
field early, so the endpoint does not lie about what the model decided.
services/hermes/NOTES.mdunder "Private voice:choosing the Piper voice from the STT-detected language".
Tests
Written before the implementation; the first run was red on 41 of them.
New
testing/tests/test_hermes_voice_language_routing.py— 90 tests, allpassing, no GPU, no microphone, no cluster:
case, whitespace, region tags, path traversal, NUL bytes, wrong types,
non-ASCII and over-long values.
.txt(which would otherwise be read as the transcript, since Hermes takesthe first glob match).
upstream anchors, and the patched modules are then imported and executed —
so these assert the behaviour of the code that actually ships, not a
re-implementation of it. Covers the agent envelope, the
/api/transcriberesponse, and the Atlas TTS branch.
/api/tts, including a battery of hostile inputs;_atlas_tts_language()is proven to return a member of the three-language setfor every one of them, and
voiceis proven unable to overridelanguage.atlas-voice.jsinside a stub DOM, fake MediaRecorder/AudioContext and a fake clock
(
testing/tests/data/atlas_voice_language_probe.js), driving complete turnsand recording what actually reaches
/api/tts: English, Russian and Spanishturns each speak in the right language; missing and unsupported languages omit
the field; hostile values are dropped; three consecutive turns
(Russian, none, Spanish) do not leak into each other; an empty transcript arms
nothing; a session change and a deactivation both discard it; a transcription
error speaks nothing at all.
Independent verification I ran outside the suite, since fixtures cannot prove
anchor fidelity against upstream:
hermes-webui-atlas-patch.pyandhermes-webui-router-patch.pyto the real files extracted from the two pinned image digests. Both
applied clean, each anchor occurs exactly once, all five new Dockerfile greps
match, and
py_compilepasses on the patchedroutes.py,upload.py,gateway_chat.pyandtranscription_tools.py.routes.py: the helper lands at module scope andthe branch that calls it is inside the function that binds
data.Other checks:
kustomize build services/hermesrenders clean and contains theupdated client script;
ruff checkpasses on every changed Python file;node --checkpasses on both JS files.Full suite, measured against a baseline I ran myself. I did not assume which
failures were pre-existing — I ran
pytest testing/on a throwaway detachedworktree of clean
origin/main@11bd04ccand compared:origin/main: 2537 passed, 4 failed, 4 skippedSame four failures, same tests, in both:
test_hermes_agent_layout.py::test_cli_lane_reserves_cpu_headroom_for_ui_and_auth,test_hermes_agent_layout.py::test_agent_avoids_unhealthy_nodes_and_fits_its_remaining_capacity,test_hermes_gitea_pr_integration.py::test_http_error_path_redacts_token,test_hermes_kanban_supervisor.py::test_auto_supervise_flag_defaults_false_in_configmap.None touch voice, STT or TTS code. The 4 skips are cluster-access and
visudochecks this sandbox cannot run. The first run of this branch had a fifth failure
that the baseline did not; that was a real regression I introduced, and fixing
it is the one-line
test_hermes_chat_quality.pychange described below.Rollout order
No deployment and no image digest bump in this PR.
Three images are involved.
Dockerfile.hermes-jetson-ttsbelongs to #26;Dockerfile.hermes-jetson-sttandDockerfile.hermes-webuibelong to this PR.hermes_stt_client.pyis not in any image — it ships through thehermes-coordinatorConfigMap withdisableNameSuffixHash: true, mounted at/opt/coordinatorand re-executed per transcription, so it takes effect on thekubelet's next volume sync without a pod restart.
main.hermes-ttsandhermes-sttdigests inservices/hermes/voice-deployment.yamland thehermes-webuidigest inservices/hermes/chat-statefulset.yaml. None of these have Flux imageautomation, so each is a manual bump.
Russian voice, a spoken English turn in English, and a typed message in
English.
The three components in this PR can roll in any order and in any partial
combination: a missing
languageat any hop degrades to English rather thanfailing, so a half-rolled state is never broken, only not-yet-multilingual. The
only genuinely order-sensitive step is #26's own manifest-versus-image hazard,
which that PR documents.
Files changed
dockerfiles/hermes-jetson-stt-server.pydockerfiles/hermes-webui-atlas-patch.pydockerfiles/hermes-webui-atlas-voice.jsdockerfiles/Dockerfile.hermes-webuiservices/hermes/scripts/hermes_stt_client.pyservices/hermes/NOTES.mdtesting/tests/test_hermes_voice_language_routing.py(new)testing/tests/data/atlas_voice_language_probe.js(new)testing/tests/test_hermes_chat_quality.py— one lineThat last one is not optional and I want to flag it, because PR #26 edits the
same file.
test_chat_voice_uses_private_jetson_services_and_shared_auto_routepins the exact text
pending=fetchSpeech(chunks[index+1])to assert the chunkprefetch pipelining.
fetchSpeechnow takes the language as a second argument,so that assertion is updated to
pending=fetchSpeech(chunks[index+1],language)— same intent, new signature.I caught it because the first full-suite run on this branch had exactly one
failure that the
origin/mainbaseline did not. #26's edits to this file arechecksum/env/annotation assertions in different tests, so this should merge
cleanly, but it is a second file both PRs touch.
Pull request closed