%!v|rM=hHpOYHm6^C!k{j$
zhJ-c!mCCtm;G!VriEAcLK=XUQ$lJMt47i7ESknklIuPE5!0$pO2JmmcT8V7BDt0*S
znU4Q2#TgtGiXd7Bha1OOIrFH*$4g7uC7f$ksx
zk4XqXRyj1!A$LU^(@x+u967(KxDc#ja2>la51+s3(6!UcU6r{T>u1j;$q_up
z3(2fyj`mDLJl)U(uhWc_Co!`VX_a%7nm+KXO;_$osK^s9sp1Fl#%imEla
zg6(N!bcC>+zEElb-F?If+MFTKS3NFtC`o+N&3WnXhRsyQY;v6nK^E&JkVn}4F5z34
z%k0=&;gI7YNRK-JLa224D@HFzH3B|7I1P82NvvFdxj?LuGzk6nWV09p~?
zb^(?#8frV}d<@c3aL(_xAhr7uYelXtd!%hza9h+G*dF$KU%~@95o}zLhL?Pr=
z$(X+dqV?yN^jdYZ*47wdFzP(g)@4n-1LnRRe_hP>0Bk{U{c?wNt)tt31%SuW
z24Udx;nUH%(>$l7xrD#6BCSF;7h68RFJGhqp5fLKhN#+@aF4}BDSq(Ss1vV2VF&4Nt0zw-fH09JIY-!~j*@hv~;8vudeH~r?Qp@H=at%#tz*}X#uz7fL4V8$f0
zKLV|w!SA?Np`KSWl^PsGdkRxqZv=l+@z%YHFAzqyP4eZ{pwtLCHMRhNzN_lIF$?NW
z1|Ho9w17UhH)Kod%T3N5n(CO)m;>w99f#*0jpTM)*t`&RQ%F}24hYf$o8)FM5g_zjL@;bL76J`LLZa^SOvmXmZ>&-
z<)`37?+5z;%)L81^VR%TyzfQ=p$guQSoI&n$GE{8l34QMbE06fz*i{9D+5q8xmQ*C
zZ8_)h`33kxQ|`<6DfMt!ClpI~6LPKf4j%uwJ*k1mB2a~b5t5%s+5^e}y*Pi&So*3j
zp`-sXXH^FlB8n!2>N`byEp@kp<5Y~4ok%nx5jQw#m5T*Y-PJPni&0dzC`A3fLF~6G
zCY8uFB()Eav(dsUT6sA7^U*v!Y3v|Li7uWmQof|7(_wh_1oewa>HIBU2b5-O1QDW2
zD%sqo?R%zBR>Ph-TZ8(>y#hgUNYx9K;?jg_Ie_v6T;~}qBLd-b3a+;(XesKWNz1>s?%%AOy)V*o@OL=4J_i;vc5Q5Kwzd4dJ~m1w{_A
zDPN3=e8h6o4>@^9Rd+dK9?)RmaW(*zsoR0zV0jY1UJ+v9q36B{D*23`N|=tiCnX4qlQBi6zhW33i}gf;g{2^
z2q}&wxTkKzH1tg$TgVQ2l~@tiO3LHOS2_wqFZmr8cE@ESV^+($Aoxj4kB}~~(48>QST`k)dS?ay{>y72Na}Qf=DP;`a5Pa5Q3p!mtOW*+m1LuB%b-K%l
zX40%S3hPAtSu3e?CVMI(?XX(tNx`@=x{Ya<=JmWqiEOwe;SMJBjW;^aF&sqDn^R_p
zuJEu>>d(`Bbu2Nj{O#P7Jj42}KR&tm=vnuM2jxw&eOF%^fX4(a
z)Q^qUJG1`PF7B#dRBjc}4t0(m&X+9|!ia5ae|9mko#0}}Dsde($tcJtErRKIU
zoFEYiYUS~2+_QyAgY``{kZ3N~sO{G79~7?w>jiidcEdzWvW-)%3iAAVUjf_4eHrgt
z2h3-)^9SEc_8W!Jd*`U(66Dx|&qY+0FJ!E6D^L)j?d}j+4+oT34ywzUUcAkD@%ZsW
zUt_Q9nRSHm@ztSvPj7J>Ou=o@^tDe^sm9B+0BrcGKGPKurBsumUWLU6m?1bYI{eQb
z^A>euC<4rmH=~~0*=s)*%^I`MVh?VU?N=2&fMH
z17J3D+lkx>18bN-()e`z7{`GO!wA&~Qo)$D#fw4Ep6f`HuS>yZ0pBnT{#YXok2G>ztE818+`Q6wuPcPw=4o9ax)n(jPH4@ZO
ze-J|VEKv&1iWZeGDWrls1fRvZ%cGth#Jo1b56VRngy*l>@w5{ZSPQ-0KXw%$nd%UG
zrDIbSSy-ai1~;q@%
zouZK|qhV>}A-#8A;pE}>IwI!N*}RSSgGG|H-QT|7hID3J
zo_gRqD!*Uxtjho{ZMh|aFDe(~B;yaj_2m3^7BV4l{#hOGFMjw!j0fj`Ct_?tW%|&2
zBUS^zCR|m1`e7R?BI>+APJz6{fSlf&YMuAGiJRTnrg(HsVln}XT7bqN6#=OQlao8f
z48UVcV#TVjzyHI4BGXcoW6SZ<2lUI4c3<_|PNGdJoF@`@-
z=4tB1hr1$6`a<*iy-k)k+=4oe3|gGudI~=@I$>#Gyh-aB*3`#lpQ(3#5h*=vp)JQ$
z9w$`^3qzJyuu4Y#q=Bpl($}mskT{{P2wng>MB+gi=BFZi&cl8&h6;4nX~;%icFD
zyMa>kRU%{+0PtBnpP*w_b!$zRCP!Yovcq*8OD}>4MvN6%#`SBGN_@WMVWFO+o-%ze
zL0+CKeT1m^(VAzS@-v|QWA!KEF@3s^1ZSw$OgfZEzZPIW6}E|#!&v?+kwtn&$0h35Ev2I}^}Xn?E7#{h$JU%iKuVKx~dT7%2^D`blW
zpW|Woe4Yv&n$>&5dZTJwy}V1j-+fuj#%!ZD(YB8q{m^Q#x&t_#eh
zS3cM`z-ECh9wo!)QU0xZtdpY3^g-WFMqsq+uPl;15h)k}h)7H5H)zABAnXEJk#a^M
Zm(tu8(7C55gFiPEAsH;<@1G+2{{S9=Qn~;D
literal 0
HcmV?d00001
diff --git a/testing/probes/generate_mediarecorder_fixture.js b/testing/probes/generate_mediarecorder_fixture.js
new file mode 100644
index 00000000..746e7778
--- /dev/null
+++ b/testing/probes/generate_mediarecorder_fixture.js
@@ -0,0 +1,84 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+async function main() {
+ const outputPath = process.argv[2];
+ if (!outputPath) throw new Error('usage: node generate_mediarecorder_fixture.js OUTPUT.json');
+
+ const browser = await chromium.launch({
+ headless: true,
+ args: ['--autoplay-policy=no-user-gesture-required'],
+ });
+ try {
+ const page = await browser.newPage();
+ const fixture = await page.evaluate(async () => {
+ const mimeType = 'audio/webm;codecs=opus';
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
+ throw new Error(`${mimeType} is not supported by this Chromium build`);
+ }
+
+ const context = new AudioContext({sampleRate: 48000});
+ const destination = context.createMediaStreamDestination();
+ const oscillator = context.createOscillator();
+ const gain = context.createGain();
+ oscillator.frequency.value = 440;
+ gain.gain.value = 0;
+ oscillator.connect(gain).connect(destination);
+ oscillator.start();
+
+ const chunks = [];
+ const recorder = new MediaRecorder(destination.stream, {mimeType});
+ recorder.ondataavailable = event => {
+ if (event.data && event.data.size) chunks.push(event.data);
+ };
+ const stopped = new Promise(resolve => { recorder.onstop = resolve; });
+ const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
+
+ recorder.start(250);
+ await delay(1400);
+ gain.gain.setValueAtTime(0.35, context.currentTime);
+ await delay(900);
+ gain.gain.setValueAtTime(0, context.currentTime);
+ await delay(500);
+ recorder.stop();
+ await stopped;
+
+ oscillator.stop();
+ await context.close();
+ const encoded = [];
+ for (const chunk of chunks) {
+ const bytes = new Uint8Array(await chunk.arrayBuffer());
+ let binary = '';
+ for (let index = 0; index < bytes.length; index += 0x8000) {
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
+ }
+ encoded.push(btoa(binary));
+ }
+ return {
+ mime_type: recorder.mimeType,
+ timeslice_ms: 250,
+ pre_speech_chunk_count: 5,
+ chunks_base64: encoded,
+ };
+ });
+ const chunks = fixture.chunks_base64.map(value => Buffer.from(value, 'base64'));
+ const payloadPath = outputPath.replace(/\.json$/i, '.webm');
+ fixture.chunk_sizes = chunks.map(chunk => chunk.length);
+ fixture.payload_file = path.basename(payloadPath);
+ delete fixture.chunks_base64;
+ fixture.browser = await browser.version();
+ fixture.generator = 'testing/probes/generate_mediarecorder_fixture.js';
+ fs.writeFileSync(payloadPath, Buffer.concat(chunks));
+ fs.writeFileSync(outputPath, `${JSON.stringify(fixture, null, 2)}\n`);
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch(error => {
+ console.error(error.stack || error);
+ process.exitCode = 1;
+});
diff --git a/testing/probes/hermes_voice_instrument_probe.js b/testing/probes/hermes_voice_instrument_probe.js
index 06b705ff..c5f7900e 100644
--- a/testing/probes/hermes_voice_instrument_probe.js
+++ b/testing/probes/hermes_voice_instrument_probe.js
@@ -2,6 +2,7 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
+const path = require('node:path');
const vm = require('node:vm');
class StyleDeclaration {
@@ -92,6 +93,7 @@ async function boot(scriptPath, reduced) {
const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}};
let rejectNextCapture = false;
let transcriptResolve;
+ let uploadedFile = null;
let sent = 0;
let lastAudio = null;
@@ -117,9 +119,10 @@ async function boot(scriptPath, reduced) {
}
class FakeMediaRecorder {
static isTypeSupported() { return true; }
- constructor(stream) {
+ constructor(stream, options) {
this.stream = stream;
this.state = 'inactive';
+ this.mimeType = options?.mimeType || 'audio/webm;codecs=opus';
this.ondataavailable = null;
this.onstop = null;
recorders.push(this);
@@ -171,11 +174,18 @@ async function boot(scriptPath, reduced) {
},
},
};
- async function fetch(url) {
+ async function fetch(url, options) {
if (url === '/api/transcribe/capability') {
return {ok: true, json: async () => ({available: true, provider: 'local_command'})};
}
if (url === '/api/transcribe') {
+ const file = options?.body?.get('file');
+ assert.ok(file, 'transcription request did not carry a file');
+ uploadedFile = {
+ bytes: new Uint8Array(await file.arrayBuffer()),
+ name: file.name,
+ type: file.type,
+ };
return new Promise(resolve => { transcriptResolve = resolve; });
}
if (url === '/api/tts') {
@@ -245,6 +255,7 @@ async function boot(scriptPath, reduced) {
captures, recorders, analysers, assistantRows, toasts, reducedMotion,
get sent() { return sent; },
get lastAudio() { return lastAudio; },
+ get uploadedFile() { return uploadedFile; },
set now(value) { now = value; },
rejectCapture() { rejectNextCapture = true; },
resolveTranscript(payload) {
@@ -261,7 +272,7 @@ async function boot(scriptPath, reduced) {
};
}
-async function normalMotionContract(scriptPath) {
+async function normalMotionContract(scriptPath, mediaFixture) {
const probe = await boot(scriptPath, false);
const originalStyleLink = probe.styleLink;
@@ -272,17 +283,38 @@ async function normalMotionContract(scriptPath) {
await flush();
assert.equal(probe.captures.length, 1);
+ const mediaChunks = mediaFixture.chunks;
+ const preSpeechCount = mediaFixture.pre_speech_chunk_count;
+ for (const chunk of mediaChunks.slice(0, preSpeechCount)) {
+ probe.recorders[0].ondataavailable({
+ data: new Blob([chunk], {type: mediaFixture.mime_type}),
+ });
+ }
+
probe.analysers[0].level = 0.3;
probe.runIntervals();
probe.runIntervals();
probe.runIntervals();
- probe.recorders[0].ondataavailable({data: new Blob(['speech'], {type: 'audio/webm'})});
+ for (const chunk of mediaChunks.slice(preSpeechCount)) {
+ probe.recorders[0].ondataavailable({
+ data: new Blob([chunk], {type: mediaFixture.mime_type}),
+ });
+ }
probe.now = 4000;
probe.analysers[0].level = 0;
probe.runIntervals();
await flush();
+ await flush();
assert.match(probe.indicator.className, /\btranscribing\b/);
assert.equal(probe.label.textContent, 'Transcribing…');
+ assert.ok(probe.uploadedFile, 'transcription upload was not captured');
+ assert.equal(probe.uploadedFile.name, 'voice-input.webm');
+ assert.equal(probe.uploadedFile.type, mediaFixture.mime_type);
+ const expectedUpload = Buffer.concat([
+ mediaChunks[0],
+ ...mediaChunks.slice(preSpeechCount - 3),
+ ]);
+ assert.deepEqual(Buffer.from(probe.uploadedFile.bytes), expectedUpload);
probe.resolveTranscript({transcript: 'Hello Hermes'});
await flush();
@@ -310,8 +342,21 @@ async function normalMotionContract(scriptPath) {
async function main() {
const scriptPath = process.argv[2];
- assert.ok(scriptPath, 'usage: node hermes_voice_instrument_probe.js ');
- const first = await normalMotionContract(scriptPath);
+ const fixturePath = process.argv[3];
+ assert.ok(
+ scriptPath && fixturePath,
+ 'usage: node hermes_voice_instrument_probe.js ',
+ );
+ const mediaFixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
+ const payload = fs.readFileSync(path.join(path.dirname(fixturePath), mediaFixture.payload_file));
+ let offset = 0;
+ mediaFixture.chunks = mediaFixture.chunk_sizes.map(size => {
+ const chunk = payload.subarray(offset, offset + size);
+ offset += size;
+ return chunk;
+ });
+ assert.equal(offset, payload.length, 'fixture chunk sizes do not cover the payload');
+ const first = await normalMotionContract(scriptPath, mediaFixture);
const {probe, originalStyleLink, capturesBeforeSpeech} = first;
assert.equal(probe.captures.length, capturesBeforeSpeech);
diff --git a/testing/tests/test_hermes_handsfree_stt.py b/testing/tests/test_hermes_handsfree_stt.py
new file mode 100644
index 00000000..f0a11f4b
--- /dev/null
+++ b/testing/tests/test_hermes_handsfree_stt.py
@@ -0,0 +1,203 @@
+"""Browser-to-ffmpeg contracts for Hermes hands-free transcription uploads."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+
+
+ROOT = Path(__file__).resolve().parents[2]
+VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
+STT_PATCHER = ROOT / "dockerfiles/hermes-webui-stt-patch.py"
+AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent-9de9c25f"
+MEDIA_FIXTURE = ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
+SAFE_CONVERSION_ERROR = (
+ "Audio conversion failed: upload is invalid, incomplete, or uses an "
+ "unsupported codec"
+)
+
+
+def _media_chunks() -> tuple[dict[str, object], list[bytes]]:
+ fixture = json.loads(MEDIA_FIXTURE.read_text(encoding="utf-8"))
+ payload = MEDIA_FIXTURE.with_name(str(fixture["payload_file"])).read_bytes()
+ chunks = []
+ offset = 0
+ for size in fixture["chunk_sizes"]:
+ end = offset + int(size)
+ chunks.append(payload[offset:end])
+ offset = end
+ assert offset == len(payload)
+ return fixture, chunks
+
+
+def _late_speech_upload(*, preserve_header: bool) -> bytes:
+ fixture, chunks = _media_chunks()
+ count = int(fixture["pre_speech_chunk_count"])
+ retained = chunks[count - 3 :]
+ if preserve_header:
+ retained.insert(0, chunks[0])
+ return b"".join(retained)
+
+
+def _patched_transcription_module(tmp_path: Path):
+ target = tmp_path / "hermes-agent"
+ shutil.copytree(AGENT_FIXTURE, target)
+ env = os.environ.copy()
+ env["HERMES_AGENT_PATCH_ROOT"] = str(target)
+ subprocess.run(
+ [sys.executable, str(STT_PATCHER)],
+ cwd=ROOT,
+ env=env,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ module_path = target / "tools/transcription_tools.py"
+ spec = importlib.util.spec_from_file_location(
+ "pinned_transcription_tools", module_path
+ )
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _convert(module, tmp_path: Path, payload: bytes, suffix: str):
+ source = tmp_path / f"voice-input{suffix}"
+ output_dir = tmp_path / f"converted-{suffix.removeprefix('.')}"
+ output_dir.mkdir()
+ source.write_bytes(payload)
+ return module._prepare_local_audio(str(source), str(output_dir))
+
+
+def test_fixture_reproduces_the_current_ebml_header_failure(tmp_path: Path):
+ """The red control is browser data after the old sliding pre-roll dropped chunk 0."""
+ broken = tmp_path / "current-late-speech.webm"
+ broken.write_bytes(_late_speech_upload(preserve_header=False))
+
+ result = subprocess.run(
+ ["ffmpeg", "-v", "error", "-y", "-i", str(broken), str(tmp_path / "bad.wav")],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+
+ assert result.returncode != 0
+ assert "EBML header" in result.stderr
+
+
+def test_header_preserved_browser_webm_reaches_real_conversion_boundary(
+ tmp_path: Path,
+):
+ module = _patched_transcription_module(tmp_path)
+ prepared, error = _convert(
+ module,
+ tmp_path,
+ _late_speech_upload(preserve_header=True),
+ ".webm",
+ )
+
+ assert error is None
+ assert prepared is not None
+ output = Path(prepared)
+ assert output.is_file()
+ assert output.read_bytes().startswith(b"RIFF")
+
+
+def test_invalid_browser_upload_returns_bounded_error_without_ffmpeg_spam(
+ tmp_path: Path,
+):
+ module = _patched_transcription_module(tmp_path)
+ prepared, error = _convert(
+ module,
+ tmp_path,
+ _late_speech_upload(preserve_header=False),
+ ".webm",
+ )
+
+ assert prepared is None
+ assert error == SAFE_CONVERSION_ERROR
+ assert len(error) < 128
+ lowered = error.lower()
+ assert "ffmpeg version" not in lowered
+ assert "configuration:" not in lowered
+ assert "/tmp/" not in lowered
+
+
+def test_local_conversion_accepts_browser_fallback_containers(tmp_path: Path):
+ module = _patched_transcription_module(tmp_path)
+ formats = ((".ogg", "libopus"), (".mp4", "aac"))
+
+ for suffix, codec in formats:
+ source = tmp_path / f"source{suffix}"
+ subprocess.run(
+ [
+ "ffmpeg",
+ "-v",
+ "error",
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "sine=frequency=440:duration=0.4",
+ "-c:a",
+ codec,
+ str(source),
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ )
+ prepared, error = _convert(module, tmp_path, source.read_bytes(), suffix)
+ assert error is None
+ assert prepared is not None
+ assert Path(prepared).read_bytes().startswith(b"RIFF")
+
+
+def test_browser_uses_actual_recorder_mime_and_supported_extensions():
+ source = VOICE_JS.read_text(encoding="utf-8")
+
+ assert "let initialChunk=null" in source
+ assert "if(initialChunk){chunks.push(initialChunk);initialChunk=null;}" in source
+ assert "let recordedMime=recorder.mimeType||mime||''" in source
+ assert "if(event.data.type) recordedMime=event.data.type" in source
+ assert "audio/mp4;codecs=mp4a.40.2" in source
+ assert "if(normalized.indexOf('ogg')>=0) return 'ogg'" in source
+ assert "if(normalized.indexOf('mp4')>=0) return 'mp4'" in source
+
+
+def test_stt_patch_is_built_fail_closed_into_the_webui_image(tmp_path: Path):
+ dockerfile = (ROOT / "dockerfiles/Dockerfile.hermes-webui").read_text(
+ encoding="utf-8"
+ )
+ assert "COPY dockerfiles/hermes-webui-stt-patch.py" in dockerfile
+ assert "python /tmp/hermes-webui-stt-patch.py" in dockerfile
+ assert "/opt/hermes/tools/transcription_tools.py" in dockerfile
+
+ drifted = tmp_path / "drifted-agent"
+ shutil.copytree(AGENT_FIXTURE, drifted)
+ transcription = drifted / "tools/transcription_tools.py"
+ source = transcription.read_text(encoding="utf-8")
+ transcription.write_text(
+ source.replace("Failed to convert audio for local STT", "upstream drift", 1),
+ encoding="utf-8",
+ )
+ env = os.environ.copy()
+ env["HERMES_AGENT_PATCH_ROOT"] = str(drifted)
+ result = subprocess.run(
+ [sys.executable, str(STT_PATCHER)],
+ cwd=ROOT,
+ env=env,
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode != 0
+ assert "patch context changed" in result.stderr
diff --git a/testing/tests/test_hermes_voice_instrument.py b/testing/tests/test_hermes_voice_instrument.py
index eada0199..5b1a69c5 100644
--- a/testing/tests/test_hermes_voice_instrument.py
+++ b/testing/tests/test_hermes_voice_instrument.py
@@ -16,6 +16,9 @@ PATCHER = ROOT / "dockerfiles/hermes-webui-atlas-patch.py"
VOICE_JS = ROOT / "dockerfiles/hermes-webui-atlas-voice.js"
VOICE_CSS = ROOT / "dockerfiles/hermes-webui-atlas-voice.css"
DOM_PROBE = ROOT / "testing/probes/hermes_voice_instrument_probe.js"
+MEDIARECORDER_FIXTURE = (
+ ROOT / "testing/fixtures/mediarecorder/chromium-webm-opus.json"
+)
def _patched_fixture(tmp_path: Path) -> Path:
@@ -86,7 +89,7 @@ def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion():
def test_dom_probe_exercises_actual_injected_voice_script():
result = subprocess.run(
- ["node", str(DOM_PROBE), str(VOICE_JS)],
+ ["node", str(DOM_PROBE), str(VOICE_JS), str(MEDIARECORDER_FIXTURE)],
cwd=ROOT,
check=True,
capture_output=True,
From 724656d84137d9736c495921eaea3eb0f8427ea1 Mon Sep 17 00:00:00 2001
From: Hermes Agent
Date: Thu, 20 Aug 2026 18:53:43 +0000
Subject: [PATCH 09/10] feat(hermes-tts): prepare fixed multilingual voice
policy
Supersede draft PR #26 with a merge-safe prerequisite: bake and preload the amy, irina, and claude Piper models, route only validated server-side language to fixed voices, and leave the live voice deployment manifest unchanged.
Remove the pinned WebUI speaker selector and its persisted preference, omit client voice fields from every outbound TTS path, and keep hands-free Voice Mode and the conversation instrument intact. Hostile or legacy voice fields remain ignored by the Piper server.
Co-Authored-By: Claude Sonnet 5
---
dockerfiles/Dockerfile.hermes-jetson-tts | 31 ++-
dockerfiles/hermes-jetson-tts-server.py | 104 +++++++--
dockerfiles/hermes-webui-atlas-patch.py | 165 ++++++++++++-
services/hermes/NOTES.md | 34 +++
.../hermes-webui-0.52.181/api/config.py | 10 +
.../hermes-webui-0.52.181/static/boot.js | 34 +++
.../hermes-webui-0.52.181/static/i18n.js | 4 +
.../hermes-webui-0.52.181/static/index.html | 6 +
.../hermes-webui-0.52.181/static/panels.js | 44 ++++
.../hermes-webui-0.52.181/static/ui.js | 22 ++
testing/tests/test_hermes_chat_quality.py | 18 +-
.../tests/test_hermes_tts_language_routing.py | 220 ++++++++++++++++++
testing/tests/test_hermes_voice_instrument.py | 49 ++++
13 files changed, 720 insertions(+), 21 deletions(-)
create mode 100644 testing/fixtures/hermes-webui-0.52.181/api/config.py
create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/boot.js
create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/i18n.js
create mode 100644 testing/fixtures/hermes-webui-0.52.181/static/panels.js
create mode 100644 testing/tests/test_hermes_tts_language_routing.py
diff --git a/dockerfiles/Dockerfile.hermes-jetson-tts b/dockerfiles/Dockerfile.hermes-jetson-tts
index deba4080..4257934a 100644
--- a/dockerfiles/Dockerfile.hermes-jetson-tts
+++ b/dockerfiles/Dockerfile.hermes-jetson-tts
@@ -24,18 +24,41 @@ ADD --checksum=sha256:f7d01dde371555732c4c314111ac79672b1a5ce2fc19266ab42178fd8d
ADD --checksum=sha256:45754dfdebb3b8661c3fc564713772deec6e064feeb5b4e9594857dc7305193a --chmod=0444 \
https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/lessac/low/en_US-lessac-low.onnx.json?download=true \
/opt/models/piper/en_US-lessac-low.onnx.json
+
+# Multilingual chat voice policy: English -> amy, Russian -> irina, Spanish ->
+# claude (Mexican Spanish, the only "claude" voice rhasspy/piper-voices
+# publishes; there is no es_ES-claude).
+ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx?download=true \
+ /opt/models/piper/en_US-amy-medium.onnx
+ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/en/en_US/amy/medium/en_US-amy-medium.onnx.json?download=true \
+ /opt/models/piper/en_US-amy-medium.onnx.json
+ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx?download=true \
+ /opt/models/piper/ru_RU-irina-medium.onnx
+ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/ru/ru_RU/irina/medium/ru_RU-irina-medium.onnx.json?download=true \
+ /opt/models/piper/ru_RU-irina-medium.onnx.json
+ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0 --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx?download=true \
+ /opt/models/piper/es_MX-claude-high.onnx
+ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d --chmod=0444 \
+ https://huggingface.co/rhasspy/piper-voices/resolve/ea046e8458f6acd997706d6e6066a022b42f6fb1/es/es_MX/claude/high/es_MX-claude-high.onnx.json?download=true \
+ /opt/models/piper/es_MX-claude-high.onnx.json
RUN chmod 0555 /opt/models /opt/models/piper
COPY dockerfiles/hermes-jetson-tts-server.py /opt/atlas/hermes-jetson-tts-server.py
RUN chmod 0555 /opt/atlas/hermes-jetson-tts-server.py
-# Load the actual pinned voice during the ARM64 build. This catches package or
-# model-format drift before the image can reach Flux.
-RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
+# Load every pinned voice during the ARM64 build, including the three baked
+# for the multilingual chat policy. This catches package or model-format
+# drift before the image can reach Flux.
+RUN python -c "import stat; from pathlib import Path; from piper import PiperVoice; p=Path('/opt/models/piper'); models=[p/'en_US-lessac-high.onnx',p/'en_US-lessac-medium.onnx',p/'en_US-lessac-low.onnx',p/'en_US-amy-medium.onnx',p/'ru_RU-irina-medium.onnx',p/'es_MX-claude-high.onnx']; assert stat.S_IMODE(p.stat().st_mode)==0o555; assert all(stat.S_IMODE(model.stat().st_mode)==0o444 for model in models); voices=[PiperVoice.load(model,Path(str(model)+'.json'),use_cuda=False,download_dir=p) for model in models]; assert all(voice.config.sample_rate>0 for voice in voices)"
ENV HERMES_TTS_HOST=0.0.0.0 \
HERMES_TTS_PORT=9001 \
- HERMES_TTS_VOICE=en_US-lessac-medium \
+ HERMES_TTS_VOICE=en_US-amy-medium \
HERMES_TTS_CACHE=/opt/models/piper \
OMP_NUM_THREADS=2 \
PYTHONDONTWRITEBYTECODE=1 \
diff --git a/dockerfiles/hermes-jetson-tts-server.py b/dockerfiles/hermes-jetson-tts-server.py
index 3abdc28b..0b962ae2 100644
--- a/dockerfiles/hermes-jetson-tts-server.py
+++ b/dockerfiles/hermes-jetson-tts-server.py
@@ -17,12 +17,50 @@ from piper import PiperConfig, PiperVoice, SynthesisConfig
HOST = os.getenv("HERMES_TTS_HOST", "0.0.0.0")
PORT = int(os.getenv("HERMES_TTS_PORT", "9001"))
-VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-lessac-high")
CACHE_DIR = Path(os.getenv("HERMES_TTS_CACHE", "/cache/piper"))
MAX_TEXT_CHARS = 5000
ONNX_THREADS = max(1, int(os.getenv("HERMES_TTS_ONNX_THREADS", "4")))
VOICE_LOCK = threading.Lock()
+# Fixed, allow-listed language -> baked voice mapping. This is the ONLY path
+# from a client-supplied string to a model name: client input is looked up
+# here and never used to build a filesystem path directly. Both "-" and "_"
+# separators and any case are accepted; anything not present here falls back
+# to DEFAULT_VOICE_NAME (safe English default), never an error and never an
+# unbaked model.
+LANGUAGE_VOICE_MAP = {
+ "en": "en_US-amy-medium",
+ "en-us": "en_US-amy-medium",
+ "ru": "ru_RU-irina-medium",
+ "ru-ru": "ru_RU-irina-medium",
+ "es": "es_MX-claude-high",
+ "es-mx": "es_MX-claude-high",
+ "es-es": "es_MX-claude-high",
+}
+BAKED_VOICE_NAMES = frozenset(LANGUAGE_VOICE_MAP.values())
+DEFAULT_VOICE_NAME = os.getenv("HERMES_TTS_VOICE", "en_US-amy-medium")
+
+
+def normalize_language(value: object) -> str | None:
+ """Lowercase and fold "_"/"-" separators; reject non-string/blank input."""
+ if not isinstance(value, str):
+ return None
+ normalized = value.strip().lower().replace("_", "-")
+ return normalized or None
+
+
+def resolve_voice_name(language: object) -> str:
+ """Map a client-supplied language to one of the baked policy voices.
+
+ Unknown, missing, or malformed language always resolves to the safe
+ default rather than raising, and the result is always a member of
+ BAKED_VOICE_NAMES.
+ """
+ normalized = normalize_language(language)
+ if normalized is None:
+ return DEFAULT_VOICE_NAME
+ return LANGUAGE_VOICE_MAP.get(normalized, DEFAULT_VOICE_NAME)
+
def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
@@ -46,7 +84,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
if self.path != "/health":
_json(self, 404, {"error": "not found"})
return
- _json(self, 200, {"ok": True, "voice": VOICE_NAME, "device": "cpu"})
+ _json(
+ self,
+ 200,
+ {
+ "ok": True,
+ "voices": sorted(self.server.voices), # type: ignore[attr-defined]
+ "default_voice": self.server.default_voice_name, # type: ignore[attr-defined]
+ "device": "cpu",
+ },
+ )
def do_POST(self) -> None:
if self.path != "/v1/audio/speech":
@@ -71,10 +118,16 @@ class SpeechHandler(BaseHTTPRequestHandler):
return
speed = min(2.0, max(0.5, speed))
+ # Policy is driven ONLY by "language". A client-supplied "voice"
+ # field is deliberately never read here; it cannot override the
+ # allow-listed mapping.
+ voice_name = resolve_voice_name(payload.get("language"))
+ voice = self.server.voices[voice_name] # type: ignore[attr-defined]
+
output = io.BytesIO()
try:
with VOICE_LOCK, wave.open(output, "wb") as wav_file:
- self.server.voice.synthesize_wav( # type: ignore[attr-defined]
+ voice.synthesize_wav(
text,
wav_file,
SynthesisConfig(length_scale=1.0 / speed),
@@ -84,6 +137,7 @@ class SpeechHandler(BaseHTTPRequestHandler):
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(audio)))
self.send_header("Cache-Control", "no-store")
+ self.send_header("X-TTS-Voice", voice_name)
self.end_headers()
self.wfile.write(audio)
except Exception as exc:
@@ -91,30 +145,54 @@ class SpeechHandler(BaseHTTPRequestHandler):
_json(self, 500, {"error": "speech synthesis failed"})
-def main() -> None:
- """Load the checksum-pinned voice from the image and serve it on CPU."""
- CACHE_DIR.mkdir(parents=True, exist_ok=True)
- model_path = CACHE_DIR / f"{VOICE_NAME}.onnx"
- config_path = CACHE_DIR / f"{VOICE_NAME}.onnx.json"
+def _load_voice(cache_dir: Path, voice_name: str, threads: int) -> PiperVoice:
+ model_path = cache_dir / f"{voice_name}.onnx"
+ config_path = cache_dir / f"{voice_name}.onnx.json"
if not model_path.exists() or not config_path.exists():
- raise RuntimeError(f"baked Piper voice is missing: {VOICE_NAME}")
+ raise RuntimeError(f"baked Piper voice is missing: {voice_name}")
with config_path.open("r", encoding="utf-8") as config_file:
config = PiperConfig.from_dict(json.load(config_file))
session_options = onnxruntime.SessionOptions()
- session_options.intra_op_num_threads = ONNX_THREADS
+ session_options.intra_op_num_threads = threads
session_options.inter_op_num_threads = 1
session = onnxruntime.InferenceSession(
str(model_path),
sess_options=session_options,
providers=["CPUExecutionProvider"],
)
- voice = PiperVoice(session=session, config=config, download_dir=CACHE_DIR)
+ return PiperVoice(session=session, config=config, download_dir=cache_dir)
+
+
+def load_voices(cache_dir: Path, threads: int) -> dict[str, PiperVoice]:
+ """Eagerly load all three policy voices.
+
+ Preload (not lazy-load-on-first-use) was chosen deliberately: measured
+ RSS on this model set is ~88MB for one voice and ~243MB for all three
+ (~+155MB versus the previous single-voice baseline), which comfortably
+ fits the pod's memory budget on the CPU-only voice node. Preloading
+ avoids a slow, request-serializing first synthesis per language and
+ keeps the fail-closed missing-model check (below) at process start
+ rather than deferring a possible crash to a live user request.
+ """
+ return {name: _load_voice(cache_dir, name, threads) for name in sorted(BAKED_VOICE_NAMES)}
+
+
+def main() -> None:
+ """Load the checksum-pinned policy voices from the image and serve them on CPU."""
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
+ if DEFAULT_VOICE_NAME not in BAKED_VOICE_NAMES:
+ raise RuntimeError(
+ f"HERMES_TTS_VOICE must name one of the baked policy voices: {sorted(BAKED_VOICE_NAMES)}"
+ )
+ voices = load_voices(CACHE_DIR, ONNX_THREADS)
print(
- f"[tts] loaded Piper voice {VOICE_NAME} on CPU with {ONNX_THREADS} ONNX threads",
+ f"[tts] loaded {len(voices)} Piper voices on CPU with {ONNX_THREADS} ONNX threads each: "
+ + ", ".join(sorted(voices)),
flush=True,
)
server = ThreadingHTTPServer((HOST, PORT), SpeechHandler)
- server.voice = voice # type: ignore[attr-defined]
+ server.voices = voices # type: ignore[attr-defined]
+ server.default_voice_name = DEFAULT_VOICE_NAME # type: ignore[attr-defined]
print(f"[tts] ready on {HOST}:{PORT}", flush=True)
server.serve_forever(poll_interval=0.25)
diff --git a/dockerfiles/hermes-webui-atlas-patch.py b/dockerfiles/hermes-webui-atlas-patch.py
index d7bccf63..98032657 100644
--- a/dockerfiles/hermes-webui-atlas-patch.py
+++ b/dockerfiles/hermes-webui-atlas-patch.py
@@ -16,6 +16,43 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
path.write_text(source.replace(before, after, count), encoding="utf-8")
+def replace_between_exact(
+ path: Path, start: str, end: str, after: str = "", count: int = 1
+) -> None:
+ """Replace one exact, bounded upstream region and fail when the pin drifts."""
+ source = path.read_text(encoding="utf-8")
+ if source.count(start) != count or source.count(end) != count:
+ raise SystemExit(
+ f"Atlas voice patch context changed in {path}: {start[:80]!r}"
+ )
+ start_index = source.index(start)
+ end_index = source.index(end, start_index) + len(end)
+ path.write_text(
+ source[:start_index] + after + source[end_index:], encoding="utf-8"
+ )
+
+
+def assert_absent(path: Path, *needles: str) -> None:
+ """Fail the image build if a removed voice-choice surface remains."""
+ source = path.read_text(encoding="utf-8")
+ remaining = [needle for needle in needles if needle in source]
+ if remaining:
+ raise SystemExit(f"Atlas voice choice remains in {path}: {remaining!r}")
+
+
+def remove_lines_containing(path: Path, *needles: str) -> None:
+ """Remove all pinned translation entries for a retired settings control."""
+ source = path.read_text(encoding="utf-8")
+ for needle in needles:
+ if needle not in source:
+ raise SystemExit(f"Atlas voice patch context changed in {path}: {needle!r}")
+ lines = source.splitlines(keepends=True)
+ path.write_text(
+ "".join(line for line in lines if not any(n in line for n in needles)),
+ encoding="utf-8",
+ )
+
+
index = ROOT / "static/index.html"
replace_exact(
index,
@@ -29,6 +66,16 @@ replace_exact(
'',
'',
)
+replace_exact(
+ index,
+ '''
+
+
Preferred voice. Populated from your browser's available voices.
+
''',
+ "",
+)
replace_exact(
index,
'',
@@ -62,11 +109,33 @@ replace_exact(
)
ui = ROOT / "static/ui.js"
+replace_exact(
+ ui,
+ ''' const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+''',
+ "",
+)
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")
+replace_exact(
+ ui,
+ " const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';\n",
+ "",
+)
replace_exact(
ui,
"body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})",
- "body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
+ "body:JSON.stringify({text:chunk, rate:rate, pitch:pitch, engine:engineOverride||'edge'})",
+)
+replace_exact(
+ ui,
+ " voice: localStorage.getItem('hermes-tts-voice')||'',\n",
+ "",
+ count=2,
)
replace_exact(
ui,
@@ -79,6 +148,94 @@ replace_exact(
"if(engine==='edge'||engine==='atlas'){\n _playEdgeTtsChunked(clean, null, engine);",
)
+panels = ROOT / "static/panels.js"
+replace_exact(panels, " tts_voice:'hermes-tts-voice',\n", "")
+replace_exact(
+ panels,
+ ''' const ttsVoiceSel=$('settingsTtsVoice');
+ if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
+''',
+ "",
+)
+replace_exact(
+ panels,
+ ''' localStorage.setItem('hermes-tts-engine',this.value);
+ window._populateTtsVoices();
+ _schedulePreferencesAutosave();''',
+ ''' localStorage.setItem('hermes-tts-engine',this.value);
+ _schedulePreferencesAutosave();''',
+)
+replace_between_exact(
+ panels,
+ " // Populate voice selector based on engine\n",
+ " // TTS rate/pitch sliders\n",
+ " // TTS speaker selection is intentionally server policy only.\n",
+)
+replace_exact(
+ panels,
+ "let _settingsSpeechChangedKeys=new Set();\n",
+ "let _settingsSpeechChangedKeys=new Set();\n"
+ "try{localStorage.removeItem('hermes-tts-voice');}catch(_){}\n",
+)
+
+boot = ROOT / "static/boot.js"
+replace_exact(
+ boot,
+ ''' voice: localStorage.getItem("hermes-tts-voice")||'',
+''',
+ "",
+)
+replace_exact(
+ boot,
+ ''' const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
+''',
+ "",
+)
+replace_exact(
+ boot,
+ " body: JSON.stringify({text: clean, voice, rate, pitch})",
+ " body: JSON.stringify({text: clean, rate, pitch})",
+)
+replace_exact(
+ boot,
+ ''' const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+''',
+ "",
+)
+replace_exact(boot, " tts_voice:'',\n", "")
+replace_exact(boot, " ['tts_voice','hermes-tts-voice'],\n", "")
+
+config = ROOT / "api/config.py"
+replace_exact(config, ' "tts_voice": "",\n', "")
+replace_exact(config, ' "tts_voice",\n', "")
+replace_exact(
+ config,
+ ''' if k == "tts_voice":
+ if not isinstance(v, str) or len(v) > 200 or "\\x00" in v:
+ continue
+''',
+ "",
+)
+
+assert_absent(index, "settingsTtsVoice", "settings_label_tts_voice")
+assert_absent(ui, "hermes-tts-voice", "voice:voice")
+assert_absent(panels, "settingsTtsVoice", "tts_voice")
+assert_absent(boot, "hermes-tts-voice", "tts_voice", "text: clean, voice")
+assert_absent(config, '"tts_voice"')
+
+i18n = ROOT / "static/i18n.js"
+remove_lines_containing(
+ i18n,
+ "settings_label_tts_voice:",
+ "settings_desc_tts_voice:",
+)
+assert_absent(i18n, "settings_label_tts_voice", "settings_desc_tts_voice")
+
routes = ROOT / "api/routes.py"
marker = " # ── ElevenLabs TTS ──────────────────────────────────────────────────\n"
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
@@ -94,10 +251,14 @@ atlas = ''' # ── Atlas private Jetson TTS ──────────
speed = max(0.5, min(2.0, 1.0 + (float(rate_str.rstrip("%")) / 100.0)))
except ValueError:
speed = 1.0
+ # No "voice" or "language" field: the WebUI has no signal for the
+ # language of the text being spoken (see NOTES.md), so voice
+ # selection is left entirely to the TTS service's own allow-listed
+ # policy (English amy) rather than sending a value that would only
+ # be ignored server-side or a fabricated language guess.
request_body = json.dumps({
"model": "piper",
"input": text,
- "voice": "en_US-lessac-high",
"speed": speed,
}).encode("utf-8")
request = Request(atlas_url, data=request_body, headers={
diff --git a/services/hermes/NOTES.md b/services/hermes/NOTES.md
index ef10bb65..0f1dcee0 100644
--- a/services/hermes/NOTES.md
+++ b/services/hermes/NOTES.md
@@ -40,6 +40,40 @@ or the WebUI. Browser chat remains available when `bot_token` is empty.
The bot token and relay key must never be added to Git or a Kubernetes Secret.
The router does not log prompt bodies, raw Telegram IDs, link codes, or tokens.
+## Private Jetson voice: multilingual TTS policy
+
+`hermes-tts` on `titan-21` bakes three checksum-pinned Piper voices and
+selects one per request from a fixed, allow-listed `language` field: `en`/
+`en-US` → `en_US-amy-medium`, `ru`/`ru-RU` → `ru_RU-irina-medium`, `es`/
+`es-MX`/`es-ES` → `es_MX-claude-high` (Piper's `claude` voice is Mexican
+Spanish; there is no Castilian `es_ES-claude`). Matching is case-insensitive
+and accepts both `_` and `-` separators. Any language that is missing,
+unrecognized, or malformed falls back to English amy rather than erroring.
+The mapping is a fixed dict from `language` to one of the three baked model
+names only — a client-supplied `voice` field is never read, so no client
+input can select or construct a model path. All three voices are preloaded
+at process start (see `dockerfiles/hermes-jetson-tts-server.py`).
+
+Hermes Chat deliberately exposes no TTS speaker/model choice. The deterministic
+WebUI image patch removes the pinned upstream voice selector, its label and
+translations, its browser/server preference persistence, and every outbound
+client `voice` field while preserving the TTS engine, speech rate/pitch,
+dictation, hands-free Voice Mode, and the conversation instrument. Legacy
+`hermes-tts-voice` browser state is deleted. Voice choice is therefore policy,
+not a client preference: validated English maps to amy, Russian to irina,
+Spanish to claude, and every unsupported or absent language falls back to amy.
+
+The private WebUI voice bridge (`dockerfiles/hermes-webui-atlas-voice.js`,
+patched into `api/routes.py` by `hermes-webui-atlas-patch.py`) has no signal
+for the language of the assistant reply it is about to speak — it sends only
+`text` and `engine`. Until the WebUI or gateway attaches an explicit
+`language` field to that request, every reply speaks in the safe English
+default regardless of its actual language. Closing that gap needs a language
+signal upstream of the TTS call (e.g. tagging the assistant turn with a
+detected/declared reply language and threading it through
+`hermes-webui-atlas-voice.js` → `api/routes.py` → the `language` field), not
+client- or server-side guessing bolted onto the TTS service itself.
+
## The one-sentence explanation
Hermes is the persistent agent runtime and control surface; Codex or the local
diff --git a/testing/fixtures/hermes-webui-0.52.181/api/config.py b/testing/fixtures/hermes-webui-0.52.181/api/config.py
new file mode 100644
index 00000000..98bd4888
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/api/config.py
@@ -0,0 +1,10 @@
+_SETTINGS_DEFAULTS = {
+ "tts_voice": "",
+}
+_SETTINGS_SPEECH_KEYS = {
+ "tts_voice",
+}
+UPSTREAM_VALIDATION_FRAGMENT = ''' if k == "tts_voice":
+ if not isinstance(v, str) or len(v) > 200 or "\x00" in v:
+ continue
+'''
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/boot.js b/testing/fixtures/hermes-webui-0.52.181/static/boot.js
new file mode 100644
index 00000000..88541e22
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/static/boot.js
@@ -0,0 +1,34 @@
+function speakWithRegisteredEngine(){
+ const _opts={
+ voice: localStorage.getItem("hermes-tts-voice")||'',
+ rate: parseFloat(localStorage.getItem("hermes-tts-rate")),
+ };
+ return _opts;
+}
+function speakWithEdge(clean){
+ const voice=localStorage.getItem("hermes-tts-voice")||"zh-CN-XiaoxiaoNeural";
+ const rate='';
+ const pitch='';
+ return fetch('/api/tts', {
+ body: JSON.stringify({text: clean, voice, rate, pitch})
+ });
+}
+function speakWithBrowser(clean){
+ const utter=new SpeechSynthesisUtterance(clean);
+ const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+ return utter;
+}
+function _mirrorSpeechSettingsFromServer(s){
+ const defaults={
+ tts_voice:'',
+ };
+ [
+ ['tts_voice','hermes-tts-voice'],
+ ].forEach(([settingKey,storageKey])=>localStorage.setItem(storageKey,s[settingKey]));
+ return defaults;
+}
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/i18n.js b/testing/fixtures/hermes-webui-0.52.181/static/i18n.js
new file mode 100644
index 00000000..aa558ec1
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/static/i18n.js
@@ -0,0 +1,4 @@
+const EN = {
+ settings_label_tts_voice: 'Voice',
+ settings_desc_tts_voice: "Preferred voice. Populated from your browser's available voices.",
+};
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/index.html b/testing/fixtures/hermes-webui-0.52.181/static/index.html
index 6eb7f63d..b8ff3aca 100644
--- a/testing/fixtures/hermes-webui-0.52.181/static/index.html
+++ b/testing/fixtures/hermes-webui-0.52.181/static/index.html
@@ -5,6 +5,12 @@
+
+
+
Preferred voice. Populated from your browser's available voices.
+
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/panels.js b/testing/fixtures/hermes-webui-0.52.181/static/panels.js
new file mode 100644
index 00000000..856dd8b1
--- /dev/null
+++ b/testing/fixtures/hermes-webui-0.52.181/static/panels.js
@@ -0,0 +1,44 @@
+const _SETTINGS_SPEECH_STORAGE_KEYS={
+ tts_engine:'hermes-tts-engine',
+ tts_voice:'hermes-tts-voice',
+ tts_rate:'hermes-tts-rate',
+};
+let _settingsSpeechChangedKeys=new Set();
+
+function _speechPreferencesPayloadFromUi(){
+ const payload={};
+ const ttsVoiceSel=$('settingsTtsVoice');
+ if(ttsVoiceSel) _setOwnedSpeechPayload(payload,'tts_voice',ttsVoiceSel.value||'');
+ return payload;
+}
+
+function loadSettingsPanel(){
+ const ttsEngineSel=$('settingsTtsEngine');
+ if(ttsEngineSel){
+ ttsEngineSel.onchange=function(){
+ localStorage.setItem('hermes-tts-engine',this.value);
+ window._populateTtsVoices();
+ _schedulePreferencesAutosave();
+ };
+ }
+ // Populate voice selector based on engine
+ const ttsVoiceSel=$('settingsTtsVoice');
+ window._populateTtsVoices=function(){
+ if(!ttsVoiceSel) return;
+ const engine=localStorage.getItem('hermes-tts-engine')||'browser';
+ const current=String(_speechSetting('tts_voice','hermes-tts-voice','')||'');
+ _syncSpeechPreferenceCache('tts_voice',current);
+ if(engine==='edge'){
+ const edgeVoices=[
+ {value:'en-US-AriaNeural',label:'Aria (English, Female)'},
+ ];
+ ttsVoiceSel.innerHTML='
';
+ edgeVoices.forEach(v=>ttsVoiceSel.appendChild(v));
+ }
+ };
+ if(ttsVoiceSel&&'speechSynthesis' in window){
+ window._populateTtsVoices();
+ ttsVoiceSel.onchange=function(){_markSpeechPreferenceChanged('tts_voice');localStorage.setItem('hermes-tts-voice',this.value);_schedulePreferencesAutosave();};
+ }
+ // TTS rate/pitch sliders
+}
diff --git a/testing/fixtures/hermes-webui-0.52.181/static/ui.js b/testing/fixtures/hermes-webui-0.52.181/static/ui.js
index e6546193..11ff370e 100644
--- a/testing/fixtures/hermes-webui-0.52.181/static/ui.js
+++ b/testing/fixtures/hermes-webui-0.52.181/static/ui.js
@@ -1,4 +1,15 @@
+function _buildBrowserUtterance(text, btn){
+ const utter=new SpeechSynthesisUtterance(text);
+ const savedVoice=localStorage.getItem('hermes-tts-voice');
+ const voices=speechSynthesis.getVoices();
+ if(savedVoice&&voices.length){
+ const match=voices.find(v=>v.name===savedVoice);
+ if(match) utter.voice=match;
+ }
+ return utter;
+}
function _playEdgeTtsChunked(text, btn){
+ const voice=localStorage.getItem('hermes-tts-voice')||'zh-CN-XiaoxiaoNeural';
return fetch('/api/tts',{body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
}
function speakSelected(clean, btn, engine){
@@ -11,3 +22,14 @@ function speakAutomatically(clean, engine){
_playEdgeTtsChunked(clean, null);
}
}
+function registeredTts(engine, clean){
+ const _opts={
+ voice: localStorage.getItem('hermes-tts-voice')||'',
+ rate: parseFloat(localStorage.getItem('hermes-tts-rate')),
+ };
+ const autoOpts={
+ voice: localStorage.getItem('hermes-tts-voice')||'',
+ pitch: parseFloat(localStorage.getItem('hermes-tts-pitch')),
+ };
+ return [engine, clean, _opts, autoOpts];
+}
diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py
index 4cf45ffb..87e08a53 100644
--- a/testing/tests/test_hermes_chat_quality.py
+++ b/testing/tests/test_hermes_chat_quality.py
@@ -398,13 +398,27 @@ def test_voice_models_are_baked_and_runtime_has_no_public_egress():
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
- assert tts_dockerfile.count("--chmod=0444") == 6
+ assert "ADD --checksum=sha256:b3a6e47b57b8c7fbe6a0ce2518161a50f59a9cdd8a50835c02cb02bdd6206c18" in tts_dockerfile
+ assert "ADD --checksum=sha256:95a23eb4d42909d38df73bb9ac7f45f597dbfcde2d1bf9526fdeaf5466977d77" in tts_dockerfile
+ assert "ADD --checksum=sha256:8ff38212d23da300bbe3705c645e6e5b9475f0bfde01558eb17813e22acaaaaa" in tts_dockerfile
+ assert "ADD --checksum=sha256:c2ec28bb38e2b59e93b959b3e40348c1afebbd272f30fed5d41205d08e98a9d7" in tts_dockerfile
+ assert "ADD --checksum=sha256:3ef40a71ea63852cd8ab7e6fa7d2ecdcfa67a0b47c9c48e3f10e02ee02083ea0" in tts_dockerfile
+ assert "ADD --checksum=sha256:1afc81f703c0e4cb3b4d7c0dca096b8b54a98806807f0170cf5eb5557723c12d" in tts_dockerfile
+ assert tts_dockerfile.count("--chmod=0444") == 12
+ assert "/opt/models/piper/en_US-amy-medium.onnx" in tts_dockerfile
+ assert "/opt/models/piper/ru_RU-irina-medium.onnx" in tts_dockerfile
+ assert "/opt/models/piper/es_MX-claude-high.onnx" in tts_dockerfile
assert "chmod 0555 /opt/models /opt/models/piper" in tts_dockerfile
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
+ assert "HERMES_TTS_VOICE=en_US-amy-medium" in tts_dockerfile
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
assert "download_voice" not in tts_server
assert "baked Piper voice is missing" in tts_server
- assert "session_options.intra_op_num_threads = ONNX_THREADS" in tts_server
+ assert "session_options.intra_op_num_threads = threads" in tts_server
+ assert 'LANGUAGE_VOICE_MAP = {' in tts_server
+ assert '"en": "en_US-amy-medium"' in tts_server
+ assert '"ru": "ru_RU-irina-medium"' in tts_server
+ assert '"es": "es_MX-claude-high"' in tts_server
policies = _documents(HERMES / "networkpolicy.yaml")
voice_policy = next(
diff --git a/testing/tests/test_hermes_tts_language_routing.py b/testing/tests/test_hermes_tts_language_routing.py
new file mode 100644
index 00000000..5a53f808
--- /dev/null
+++ b/testing/tests/test_hermes_tts_language_routing.py
@@ -0,0 +1,220 @@
+"""Language allow-list contracts for the private Hermes chat TTS voice policy."""
+
+from __future__ import annotations
+
+import importlib.util
+import io
+import json
+import sys
+from types import SimpleNamespace
+
+import pytest
+
+from testing.tests.test_hermes_chat_support import ROOT
+
+AMY = "en_US-amy-medium"
+IRINA = "ru_RU-irina-medium"
+CLAUDE = "es_MX-claude-high"
+
+
+def _load_tts_server(monkeypatch):
+ server_path = ROOT / "dockerfiles" / "hermes-jetson-tts-server.py"
+ spec = importlib.util.spec_from_file_location("hermes_jetson_tts_server", server_path)
+ assert spec and spec.loader
+ module = importlib.util.module_from_spec(spec)
+
+ class _FakeSessionOptions:
+ def __init__(self) -> None:
+ self.intra_op_num_threads = None
+ self.inter_op_num_threads = None
+
+ fake_onnxruntime = SimpleNamespace(
+ SessionOptions=_FakeSessionOptions,
+ InferenceSession=lambda *a, **k: SimpleNamespace(),
+ )
+ fake_piper = SimpleNamespace(
+ PiperConfig=SimpleNamespace(from_dict=lambda d: d),
+ PiperVoice=lambda **kwargs: SimpleNamespace(**kwargs),
+ SynthesisConfig=lambda **kwargs: SimpleNamespace(**kwargs),
+ )
+ monkeypatch.setitem(sys.modules, "onnxruntime", fake_onnxruntime)
+ monkeypatch.setitem(sys.modules, "piper", fake_piper)
+ spec.loader.exec_module(module)
+ return module
+
+
+@pytest.fixture
+def tts(monkeypatch):
+ return _load_tts_server(monkeypatch)
+
+
+@pytest.mark.parametrize(
+ "language,expected",
+ [
+ ("en", AMY),
+ ("en-US", AMY),
+ ("en_US", AMY),
+ ("EN", AMY),
+ ("En-Us", AMY),
+ ("ru", IRINA),
+ ("ru-RU", IRINA),
+ ("ru_RU", IRINA),
+ ("RU", IRINA),
+ ("es", CLAUDE),
+ ("es-MX", CLAUDE),
+ ("es_MX", CLAUDE),
+ ("es-ES", CLAUDE),
+ ("es_ES", CLAUDE),
+ ("ES", CLAUDE),
+ ],
+)
+def test_allow_listed_languages_resolve_to_the_approved_voice(tts, language, expected):
+ assert tts.resolve_voice_name(language) == expected
+
+
+@pytest.mark.parametrize(
+ "language",
+ [
+ None,
+ "",
+ " ",
+ "fr",
+ "fr-FR",
+ "de-DE",
+ "xx",
+ "en-GB",
+ "es-AR",
+ "english",
+ 123,
+ 1.5,
+ True,
+ [],
+ {},
+ {"lang": "ru"},
+ "../../etc/passwd",
+ "en_US-amy-medium/../../ru_RU-irina-medium",
+ "\x00ru",
+ "ru\x00",
+ ],
+)
+def test_unknown_missing_or_malformed_language_falls_back_to_amy(tts, language):
+ assert tts.resolve_voice_name(language) == AMY
+
+
+def test_default_voice_name_matches_the_dockerfile_env_default(tts):
+ assert tts.DEFAULT_VOICE_NAME == AMY
+
+
+def test_resolved_voice_is_always_one_of_the_three_baked_names(tts):
+ assert frozenset({AMY, IRINA, CLAUDE}) == tts.BAKED_VOICE_NAMES
+ fuzz_inputs = [
+ "en", "ru", "es", "unknown", "", None, 42, "../../../etc/shadow",
+ "en_US-amy-medium\x00; rm -rf /", "RU-ru", "Es-Es", "en-us-extra",
+ ]
+ for value in fuzz_inputs:
+ assert tts.resolve_voice_name(value) in tts.BAKED_VOICE_NAMES
+
+
+def test_client_voice_field_cannot_override_the_language_policy(tts):
+ """The POST handler must select the voice from "language" only.
+
+ A malicious or stale "voice" field in a hostile/legacy request must never
+ change which baked model answers the request.
+ """
+ calls: list[str] = []
+
+ class _RecordingVoice:
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+ def synthesize_wav(self, text, wav_file, syn_config) -> None:
+ calls.append(self.name)
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(16_000)
+ wav_file.writeframes(b"\x00\x00")
+
+ class _RecordingHandler(tts.SpeechHandler):
+ def __init__(self, payload):
+ request = json.dumps(payload).encode("utf-8")
+ self.path = "/v1/audio/speech"
+ self.headers = {"Content-Length": str(len(request))}
+ self.rfile = io.BytesIO(request)
+ self.wfile = io.BytesIO()
+ self.status = None
+ self.response_headers = {}
+ self.server = SimpleNamespace(
+ voices={
+ AMY: _RecordingVoice(AMY),
+ IRINA: _RecordingVoice(IRINA),
+ CLAUDE: _RecordingVoice(CLAUDE),
+ },
+ default_voice_name=AMY,
+ )
+
+ def send_response(self, status, message=None):
+ self.status = status
+
+ def send_header(self, name, value):
+ self.response_headers[name] = value
+
+ def end_headers(self):
+ return None
+
+ # A payload that supplies an attacker/legacy "voice" value but no
+ # language must resolve to the safe default, never the "voice" value.
+ handler = _RecordingHandler({"input": "hi", "voice": IRINA})
+ handler.do_POST()
+ assert handler.status == 200
+ assert handler.response_headers["X-TTS-Voice"] == AMY
+
+ # A payload supplying both must still be governed by "language" alone.
+ handler = _RecordingHandler({"input": "hi", "voice": CLAUDE, "language": "ru"})
+ handler.do_POST()
+ assert handler.status == 200
+ assert handler.response_headers["X-TTS-Voice"] == IRINA
+
+ assert calls == [AMY, IRINA]
+
+
+def test_no_client_string_reaches_a_filesystem_path(tts):
+ """resolve_voice_name must only ever return a fixed, baked literal.
+
+ This is the property that keeps a client from ever causing the server to
+ build a Path out of attacker-controlled text: the return value is always
+ a member of the fixed allow-list, regardless of input shape.
+ """
+ hostile_inputs = [
+ "../../../../etc/passwd",
+ "/etc/passwd",
+ "en_US-amy-medium/../../../etc/passwd",
+ "ru_RU-irina-medium\x00.onnx",
+ "es_MX-claude-high; cat /etc/shadow",
+ "\n\ren",
+ "en" + "/" * 200,
+ " ",
+ ]
+ for value in hostile_inputs:
+ result = tts.resolve_voice_name(value)
+ assert result in tts.BAKED_VOICE_NAMES
+ assert "/" not in result
+ assert ".." not in result
+ assert "\x00" not in result
+
+
+def test_normalize_language_rejects_non_string_input(tts):
+ assert tts.normalize_language(None) is None
+ assert tts.normalize_language(123) is None
+ assert tts.normalize_language([]) is None
+ assert tts.normalize_language("") is None
+ assert tts.normalize_language(" ") is None
+ assert tts.normalize_language("En_US") == "en-us"
+
+
+def test_default_voice_name_is_one_of_the_baked_voices(tts):
+ assert tts.DEFAULT_VOICE_NAME in tts.BAKED_VOICE_NAMES
+
+
+def test_load_voices_fails_closed_when_a_baked_model_is_missing(tts, tmp_path):
+ with pytest.raises(RuntimeError, match="baked Piper voice is missing"):
+ tts.load_voices(tmp_path, threads=1)
diff --git a/testing/tests/test_hermes_voice_instrument.py b/testing/tests/test_hermes_voice_instrument.py
index 5b1a69c5..a682c80a 100644
--- a/testing/tests/test_hermes_voice_instrument.py
+++ b/testing/tests/test_hermes_voice_instrument.py
@@ -60,6 +60,55 @@ def test_real_upstream_fixture_receives_visual_instrument_contract(tmp_path: Pat
assert '