2026-08-10 00:42:37 -03:00
#!/usr/bin/env python3
""" Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI. """
2026-08-20 23:13:51 +00:00
import os
2026-08-10 00:42:37 -03:00
from pathlib import Path
2026-08-20 23:13:51 +00:00
ROOT = Path ( os . environ . get ( " HERMES_WEBUI_PATCH_ROOT " , " /opt/hermes-webui " ) )
2026-08-21 11:52:49 +00:00
# The WebUI imports the pinned agent's STT tooling from the same image, so the
# local-command transcription envelope is patched alongside the WebUI itself.
AGENT_ROOT = Path ( os . environ . get ( " HERMES_AGENT_PATCH_ROOT " , " /opt/hermes " ) )
2026-08-10 00:42:37 -03:00
def replace_exact ( path : Path , before : str , after : str , count : int = 1 ) - > None :
""" Replace an exact upstream fragment and fail when the pin has drifted. """
source = path . read_text ( encoding = " utf-8 " )
if source . count ( before ) != count :
raise SystemExit ( f " Atlas voice patch context changed in { path } : { before [ : 80 ] !r} " )
path . write_text ( source . replace ( before , after , count ) , encoding = " utf-8 " )
2026-08-20 18:53:43 +00:00
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 " ,
)
2026-08-10 00:42:37 -03:00
index = ROOT / " static/index.html "
2026-08-20 23:13:51 +00:00
replace_exact (
index ,
' <link rel= " stylesheet " href= " static/style.css?v=__WEBUI_VERSION__ " > ' ,
' <link rel= " stylesheet " href= " static/style.css?v=__WEBUI_VERSION__ " > \n '
' <link id= " voiceInstrumentStyles " rel= " stylesheet " '
' href= " static/atlas-voice.css?v=__WEBUI_VERSION__ " > ' ,
)
2026-08-10 00:42:37 -03:00
replace_exact (
index ,
' <option value= " browser " >Browser speech synthesis</option><option value= " edge " >Edge TTS (server)</option> ' ,
' <option value= " atlas " >Atlas Jetson (private)</option><option value= " browser " >Browser speech synthesis</option><option value= " edge " >Edge TTS (server)</option> ' ,
)
2026-08-20 18:53:43 +00:00
replace_exact (
index ,
''' <div class= " settings-field " ><label for= " settingsTtsVoice " data-i18n= " settings_label_tts_voice " >Voice</label>
< select id = " settingsTtsVoice " style = " width:100 % ;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px " >
< option value = " " > Default system voice < / option >
< / select >
< div style = " font-size:11px;color:var(--muted);margin-top:4px " data - i18n = " settings_desc_tts_voice " > Preferred voice . Populated from your browser ' s available voices.</div>
< / div > ''' ,
" " ,
)
2026-08-10 00:42:37 -03:00
replace_exact (
index ,
' <script src= " static/boot.js?v=__WEBUI_VERSION__ " defer></script> ' ,
' <script src= " static/boot.js?v=__WEBUI_VERSION__ " defer></script> \n <script src= " static/atlas-voice.js?v=__WEBUI_VERSION__ " defer></script> ' ,
)
2026-08-20 23:13:51 +00:00
replace_exact (
index ,
''' <div class= " voice-mode-bar " id= " voiceModeBar " style= " display:none " >
< span class = " voice-mode-indicator " id = " voiceModeIndicator " > < / span >
< span class = " voice-mode-label " id = " voiceModeLabel " > < / span >
< / div > ''' ,
''' <div class= " voice-mode-bar " id= " voiceModeBar " style= " display:none " role= " status " aria-live= " polite " aria-atomic= " true " >
< span class = " voice-mode-indicator idle " id = " voiceModeIndicator " aria - hidden = " true " >
< span class = " voice-instrument-halo " > < / span >
< span class = " voice-instrument-ripple " > < / span >
< span class = " voice-instrument-orbit " > < / span >
< span class = " voice-instrument-core " >
< span class = " voice-instrument-symbol " >
< svg viewBox = " 0 0 24 24 " fill = " none " stroke = " currentColor " stroke - width = " 1.7 " stroke - linecap = " round " stroke - linejoin = " round " focusable = " false " >
< g class = " voice-symbol voice-symbol-listening " > < rect x = " 9 " y = " 3 " width = " 6 " height = " 11 " rx = " 3 " / > < path d = " M6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v3M9 20h6 " / > < / g >
< g class = " voice-symbol voice-symbol-transcribing " > < path d = " M5 7h14M5 12h10M5 17h7 " / > < path d = " M18 15v5m-2.5-2.5L18 20l2.5-2.5 " / > < / g >
< g class = " voice-symbol voice-symbol-thinking " > < path d = " M12 3l1.15 4.1L17 8.5l-3.85 1.4L12 14l-1.15-4.1L7 8.5l3.85-1.4L12 3Z " / > < path d = " M18.5 13.5l.65 2.35 2.35.65-2.35.65-.65 2.35-.65-2.35-2.35-.65 2.35-.65.65-2.35Z " / > < path d = " M5.5 14l.45 1.55L7.5 16l-1.55.45L5.5 18l-.45-1.55L3.5 16l1.55-.45L5.5 14Z " / > < / g >
< g class = " voice-symbol voice-symbol-speaking " > < path d = " M5 10v4h3l4 3V7L8 10H5Z " / > < path d = " M15.5 9.25a4 4 0 0 1 0 5.5M18 7a7 7 0 0 1 0 10 " / > < / g >
< g class = " voice-symbol voice-symbol-error " > < path d = " M12 4 21 20H3L12 4Z " / > < path d = " M12 9v5M12 17.2v.1 " / > < / g >
< / svg >
< / span >
< / span >
< / span >
< span class = " voice-mode-label " id = " voiceModeLabel " > < / span >
< / div > ''' ,
)
2026-08-10 00:42:37 -03:00
2026-08-23 18:29:02 -03:00
service_worker = ROOT / " static/sw.js "
replace_exact (
service_worker ,
" ' ./static/style.css ' + VQ, \n " ,
" ' ./static/style.css ' + VQ, \n "
" ' ./static/atlas-voice.css ' + VQ, \n "
" ' ./static/atlas-voice.js ' + VQ, \n "
" ' ./static/atlas-voice-worklet.js ' + VQ, \n " ,
)
2026-08-10 00:42:37 -03:00
ui = ROOT / " static/ui.js "
2026-08-20 18:53:43 +00:00
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 ;
}
''' ,
" " ,
)
2026-08-10 00:42:37 -03:00
replace_exact ( ui , " function _playEdgeTtsChunked(text, btn) { " , " function _playEdgeTtsChunked(text, btn, engineOverride) { " )
2026-08-20 18:53:43 +00:00
replace_exact (
ui ,
" const voice=localStorage.getItem( ' hermes-tts-voice ' )|| ' zh-CN-XiaoxiaoNeural ' ; \n " ,
" " ,
)
2026-08-10 00:42:37 -03:00
replace_exact (
ui ,
" body:JSON.stringify( { text:chunk, voice:voice, rate:rate, pitch:pitch}) " ,
2026-08-20 18:53:43 +00:00
" body:JSON.stringify( { text:chunk, rate:rate, pitch:pitch, engine:engineOverride|| ' edge ' }) " ,
)
replace_exact (
ui ,
" voice: localStorage.getItem( ' hermes-tts-voice ' )|| ' ' , \n " ,
" " ,
count = 2 ,
2026-08-10 00:42:37 -03:00
)
replace_exact (
ui ,
" if(engine=== ' edge ' ) { \n _playEdgeTtsChunked(clean, btn); " ,
" if(engine=== ' edge ' ||engine=== ' atlas ' ) { \n _playEdgeTtsChunked(clean, btn, engine); " ,
)
replace_exact (
ui ,
" if(engine=== ' edge ' ) { \n _playEdgeTtsChunked(clean, null); " ,
" if(engine=== ' edge ' ||engine=== ' atlas ' ) { \n _playEdgeTtsChunked(clean, null, engine); " ,
)
2026-08-20 18:53:43 +00:00
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 " )
2026-08-21 11:52:49 +00:00
# The private Whisper service reports the language it decoded with. Carry that
# through the agent's local-command STT envelope so the WebUI can hand a voice
# hint to Piper instead of guessing the reply's language from its text.
transcription = AGENT_ROOT / " tools/transcription_tools.py "
replace_exact (
transcription ,
''' transcript_text = txt_files[0].read_text(encoding= " utf-8 " ).strip()
logger . info (
" Transcribed %s via local STT command ( %s , %d chars) " ,
Path ( file_path ) . name ,
normalized_model ,
len ( transcript_text ) ,
)
return { " success " : True , " transcript " : transcript_text , " provider " : " local_command " }
''' ,
''' transcript_text = txt_files[0].read_text(encoding= " utf-8 " ).strip()
logger . info (
" Transcribed %s via local STT command ( %s , %d chars) " ,
Path ( file_path ) . name ,
normalized_model ,
len ( transcript_text ) ,
)
detected_language = " "
language_files = sorted ( Path ( output_dir ) . glob ( " *.language " ) )
if language_files :
try :
candidate = language_files [ 0 ] . read_text ( encoding = " utf-8 " ) . strip ( ) . lower ( )
except ( OSError , ValueError ) :
candidate = " "
if 2 < = len ( candidate ) < = 3 and candidate . isascii ( ) and candidate . isalpha ( ) :
detected_language = candidate
return {
" success " : True ,
" transcript " : transcript_text ,
" provider " : " local_command " ,
" language " : detected_language ,
}
''' ,
)
upload = ROOT / " api/upload.py "
replace_exact (
upload ,
""" transcript = str(result.get( ' transcript ' ) or ' ' ).strip()
return j ( handler , { ' ok ' : True , ' transcript ' : transcript } )
""" ,
""" transcript = str(result.get( ' transcript ' ) or ' ' ).strip()
detected = str ( result . get ( ' language ' ) or ' ' ) . strip ( ) . lower ( )
if not ( 2 < = len ( detected ) < = 3 and detected . isascii ( ) and detected . isalpha ( ) ) :
detected = ' '
return j ( handler , { ' ok ' : True , ' transcript ' : transcript , ' language ' : detected } )
""" ,
)
2026-08-10 00:42:37 -03:00
routes = ROOT / " api/routes.py "
2026-08-23 18:29:02 -03:00
replace_exact (
routes ,
" import html as _html \n " ,
" import base64 \n import html as _html \n import secrets \n " ,
)
2026-08-21 11:52:49 +00:00
replace_exact (
routes ,
" def _tts_open(req, *, timeout=30, opener_factory=None): " ,
''' ATLAS_TTS_LANGUAGES = ( " en " , " ru " , " es " )
2026-08-23 22:13:52 -03:00
ATLAS_TTS_CUE_IDS = ( " thinking " , " let_me_think " , " still_working " , " one_more_moment " )
2026-08-21 11:52:49 +00:00
def _atlas_tts_language ( body ) :
""" Return a plain, allow-listed en/ru/es code, or " " to send no language.
This is a trust boundary , not a parser . Only the exact normalized codes the
private Piper deployment bakes a voice for are forwarded ; a missing field ,
a wrong type , a region tag , padding , control characters , a traversal or
injection string , an oversized value , an object , an array , a number or a
client - supplied " voice " all resolve to " " and the language field is then
omitted entirely , so the Jetson service applies its own English default .
Coercing a malformed value into a supported code would let a browser
describe hostile input as a language we support ; omission cannot .
"""
if not isinstance ( body , dict ) :
return " "
value = body . get ( " language " )
if not isinstance ( value , str ) :
return " "
return value if value in ATLAS_TTS_LANGUAGES else " "
def _tts_open ( req , * , timeout = 30 , opener_factory = None ) : ''' ,
)
2026-08-23 18:29:02 -03:00
replace_exact (
routes ,
" def _tts_open(req, *, timeout=30, opener_factory=None): " ,
''' ATLAS_TTS_STREAM_URL = " http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech/stream "
ATLAS_STT_STREAM_URL = " http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions/stream "
2026-08-23 22:13:52 -03:00
ATLAS_VOICE_PREFLIGHT_URL = " http://hermes-switchyard.hermes.svc.cluster.local:9009/voice/route-preflight "
2026-08-23 18:29:02 -03:00
ATLAS_VOICE_WS_PROTOCOL = " hermes-voice-v1 "
ATLAS_VOICE_MAX_BYTES = 8 * 1024 * 1024
ATLAS_VOICE_DEADLINE_SECONDS = 100
2026-08-23 22:13:52 -03:00
ATLAS_VOICE_PREFLIGHT_TIERS = ( " fast " , " balanced " , " deep " , " maximum " )
ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES = 1024
2026-08-23 18:29:02 -03:00
def _atlas_exact_stream_url ( env_name , expected ) :
""" Return an exact private service URL, never a browser-selected target. """
configured = os . getenv ( env_name , " " ) . strip ( )
return configured if configured == expected else " "
def _handle_atlas_streaming_capability ( handler ) :
""" Advertise only transports whose immutable in-cluster URLs are configured. """
tts = bool ( _atlas_exact_stream_url ( " HERMES_WEBUI_ATLAS_TTS_STREAM_URL " , ATLAS_TTS_STREAM_URL ) )
stt = bool ( _atlas_exact_stream_url ( " HERMES_WEBUI_ATLAS_STT_STREAM_URL " , ATLAS_STT_STREAM_URL ) )
2026-08-23 22:13:52 -03:00
preflight = True
2026-08-23 18:29:02 -03:00
j ( handler , {
" tts " : {
" available " : tts ,
" transport " : " http " ,
" format " : " pcm_s16le " ,
" sample_rate " : 22050 ,
} ,
" stt " : {
" available " : stt ,
" transport " : " websocket " ,
" path " : " /api/transcribe/stream " ,
" format " : " pcm_s16le " ,
" sample_rate " : 16000 ,
} ,
2026-08-23 22:13:52 -03:00
" preflight " : {
" available " : preflight ,
" path " : " /api/voice/route-preflight " ,
" advisory " : True ,
} ,
2026-08-23 18:29:02 -03:00
} , extra_headers = { " Cache-Control " : " no-store " } )
return True
2026-08-23 22:13:52 -03:00
def _atlas_voice_preflight_payload ( data ) :
""" Validate a provisional transcript without accepting routing authority. """
if not isinstance ( data , dict ) :
raise ValueError ( " invalid request body " )
turn_id = data . get ( " turn_id " )
revision = data . get ( " revision " )
transcript = data . get ( " transcript " )
if not isinstance ( turn_id , str ) or not re . fullmatch ( r " [A-Za-z0-9._:-] { 1,128} " , turn_id ) :
raise ValueError ( " invalid turn_id " )
if isinstance ( revision , bool ) or not isinstance ( revision , int ) or not 1 < = revision < = 1000000 :
raise ValueError ( " invalid revision " )
if not isinstance ( transcript , str ) :
raise ValueError ( " invalid transcript " )
transcript = " " . join ( transcript . split ( ) )
if not 12 < = len ( transcript ) < = 512 :
raise ValueError ( " invalid transcript " )
return { " turn_id " : turn_id , " revision " : revision , " transcript " : transcript }
def _atlas_voice_preflight_open ( request , timeout = 1.0 ) :
""" Open only the immutable in-cluster advisory endpoint without proxies. """
return build_opener ( ProxyHandler ( { } ) , _NoRedirectTtsHandler ( ) ) . open ( request , timeout = timeout )
def _handle_atlas_voice_preflight ( handler ) :
""" Relay one same-origin local advisory and expose no classifier output. """
if not _check_same_origin_browser_request ( handler ) :
return bad ( handler , " Voice route preflight origin validation failed " , 403 )
target = ATLAS_VOICE_PREFLIGHT_URL
try :
payload = _atlas_voice_preflight_payload ( read_body ( handler ) )
except ( TypeError , ValueError ) as exc :
return bad ( handler , str ( exc ) , 400 )
request = Request (
target ,
data = json . dumps ( payload , separators = ( " , " , " : " ) ) . encode ( " utf-8 " ) ,
headers = { " Content-Type " : " application/json " , " Accept " : " application/json " } ,
)
try :
with _atlas_voice_preflight_open ( request , timeout = 1.0 ) as upstream :
raw = upstream . read ( ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES + 1 )
if len ( raw ) > ATLAS_VOICE_PREFLIGHT_MAX_RESPONSE_BYTES :
raise ValueError ( " oversized advisory response " )
result = json . loads ( raw )
tier = result . get ( " tier " ) if isinstance ( result , dict ) else None
target_hint = result . get ( " target " ) if isinstance ( result , dict ) else None
if (
result . get ( " turn_id " ) != payload [ " turn_id " ]
or result . get ( " revision " ) != payload [ " revision " ]
or tier not in ATLAS_VOICE_PREFLIGHT_TIERS
or target_hint != " atlas/auto/ " + tier
or result . get ( " advisory " ) is not True
) :
raise ValueError ( " invalid advisory response " )
except Exception :
# Advisory failure never changes or delays the final Switchyard request.
return bad ( handler , " Voice route preflight unavailable " , 503 )
return j (
handler ,
{
" turn_id " : payload [ " turn_id " ] ,
" revision " : payload [ " revision " ] ,
" tier " : tier ,
" target " : target_hint ,
" advisory " : True ,
} ,
extra_headers = { " Cache-Control " : " no-store " } ,
)
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
def _atlas_tts_speed ( body ) :
""" Clamp the optional client speech-rate to Piper ' s supported 0.5-2.0.
The value is a UX preference , not a trust decision : a missing , boolean ,
non - numeric or NaN value falls back to the neutral 1.0 this proxy always
sent before the hands - free speed became client - tunable . Infinities clamp
to the range bounds like any other out - of - range number .
"""
if not isinstance ( body , dict ) :
return 1.0
value = body . get ( " speed " )
if isinstance ( value , bool ) or not isinstance ( value , ( int , float ) ) :
return 1.0
speed = float ( value )
if speed != speed :
return 1.0
return max ( 0.5 , min ( 2.0 , speed ) )
2026-08-23 18:29:02 -03:00
def _atlas_tts_stream_payload ( data ) :
""" Build the narrow Piper payload used by the raw-PCM stream endpoint. """
if not isinstance ( data , dict ) :
raise ValueError ( " invalid request body " )
text = data . get ( " text " )
if not isinstance ( text , str ) or not text . strip ( ) :
raise ValueError ( " text is required " )
text = text . strip ( )
if len ( text ) > 500 :
raise ValueError ( " text too long (max 500 characters) " )
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
payload = { " model " : " piper " , " input " : text , " speed " : _atlas_tts_speed ( data ) }
2026-08-23 18:29:02 -03:00
language = _atlas_tts_language ( data )
if language :
payload [ " language " ] = language
2026-08-23 22:13:52 -03:00
cue_id = data . get ( " cue_id " )
if " cue_id " in data :
if not language or cue_id not in ATLAS_TTS_CUE_IDS :
raise ValueError ( " invalid thinking cue " )
payload [ " cue_id " ] = cue_id
2026-08-23 18:29:02 -03:00
turn_id = data . get ( " turn_id " )
2026-08-23 22:13:52 -03:00
if isinstance ( turn_id , str ) and re . fullmatch ( r " [A-Za-z0-9._:-] { 1,128} " , turn_id ) :
2026-08-23 18:29:02 -03:00
payload [ " turn_id " ] = turn_id
return payload
def _handle_atlas_tts_stream ( handler ) :
""" Relay bounded private Piper PCM using HTTP/1.1 chunk framing. """
target = _atlas_exact_stream_url ( " HERMES_WEBUI_ATLAS_TTS_STREAM_URL " , ATLAS_TTS_STREAM_URL )
if not target :
return bad ( handler , " Atlas streaming TTS is not configured " , 503 )
headers_sent = False
try :
payload = _atlas_tts_stream_payload ( read_body ( handler ) )
except ( TypeError , ValueError ) as exc :
return bad ( handler , str ( exc ) , 400 )
request = Request (
target ,
data = json . dumps ( payload ) . encode ( " utf-8 " ) ,
headers = { " Content-Type " : " application/json " , " Accept " : " audio/pcm " } ,
)
try :
upstream = _tts_open (
request ,
timeout = 45 ,
opener_factory = lambda : build_opener ( ProxyHandler ( { } ) , _NoRedirectTtsHandler ( ) ) ,
)
with upstream :
content_type = str ( upstream . headers . get ( " Content-Type " , " " ) ) . split ( " ; " , 1 ) [ 0 ] . lower ( )
if content_type not in ( " audio/l16 " , " audio/pcm " , " application/octet-stream " ) :
raise ValueError ( " Atlas streaming TTS returned an unsupported format " )
try :
sample_rate = int ( upstream . headers . get ( " X-Audio-Sample-Rate " , " 22050 " ) )
channels = int ( upstream . headers . get ( " X-Audio-Channels " , " 1 " ) )
except ( TypeError , ValueError ) :
raise ValueError ( " Atlas streaming TTS returned invalid audio metadata " )
if not 8000 < = sample_rate < = 96000 or channels != 1 :
raise ValueError ( " Atlas streaming TTS returned invalid audio metadata " )
handler . send_response ( 200 )
handler . send_header (
" Content-Type " ,
f " audio/pcm;rate= { sample_rate } ;channels=1;encoding=signed-integer;bits=16;endian=little " ,
)
handler . send_header ( " X-Audio-Sample-Rate " , str ( sample_rate ) )
handler . send_header ( " X-Audio-Channels " , " 1 " )
handler . send_header ( " Cache-Control " , " no-store " )
handler . send_header ( " Transfer-Encoding " , " chunked " )
handler . end_headers ( )
headers_sent = True
sent = 0
while True :
chunk = upstream . read ( 16384 )
if not chunk :
break
sent + = len ( chunk )
if sent > ATLAS_VOICE_MAX_BYTES :
raise ValueError ( " Atlas streaming TTS exceeded its audio limit " )
handler . wfile . write ( ( " %x \\ r \\ n " % len ( chunk ) ) . encode ( " ascii " ) )
handler . wfile . write ( chunk )
handler . wfile . write ( b " \\ r \\ n " )
handler . wfile . flush ( )
handler . wfile . write ( b " 0 \\ r \\ n \\ r \\ n " )
handler . wfile . flush ( )
return True
except ( BrokenPipeError , ConnectionResetError ) :
return True
except Exception :
logger . exception ( " Atlas streaming TTS generation failed " )
if not headers_sent :
return bad ( handler , " Atlas streaming TTS generation failed " , 502 )
handler . close_connection = True
return True
def _atlas_ws_protocols ( handler ) :
return [ value . strip ( ) for value in handler . headers . get ( " Sec-WebSocket-Protocol " , " " ) . split ( " , " ) if value . strip ( ) ]
def _atlas_ws_authorized ( handler ) :
""" Require same-origin plus the rendered session CSRF token when enabled. """
origin = handler . headers . get ( " Origin " , " " ) . strip ( )
if not origin or not _check_same_origin_browser_request ( handler ) :
return False
protocols = _atlas_ws_protocols ( handler )
if ATLAS_VOICE_WS_PROTOCOL not in protocols :
return False
from api . auth import csrf_token_for_session , is_auth_enabled , parse_cookie , verify_session
if not is_auth_enabled ( ) :
return True
cookie = parse_cookie ( handler )
if not cookie :
cookie = getattr ( handler , " _trusted_auth_session_cookie_value " , None )
if not cookie or not verify_session ( cookie ) :
return False
expected = csrf_token_for_session ( cookie ) or " "
supplied = next ( ( value . removeprefix ( " hermes-csrf. " ) for value in protocols if value . startswith ( " hermes-csrf. " ) ) , " " )
return bool ( expected and supplied and secrets . compare_digest ( expected , supplied ) )
def _atlas_ws_upstream_handshake ( target ) :
parsed = urlsplit ( target )
if parsed . scheme != " http " or not parsed . hostname or parsed . port is None :
raise ValueError ( " invalid private streaming STT URL " )
upstream = _socket . create_connection ( ( parsed . hostname , parsed . port ) , timeout = 5 )
upstream . settimeout ( 5 )
key = base64 . b64encode ( os . urandom ( 16 ) ) . decode ( " ascii " )
path = parsed . path or " / "
if parsed . query :
path + = " ? " + parsed . query
request = (
f " GET { path } HTTP/1.1 \\ r \\ nHost: { parsed . hostname } : { parsed . port } \\ r \\ n "
" Upgrade: websocket \\ r \\ nConnection: Upgrade \\ r \\ n "
f " Sec-WebSocket-Key: { key } \\ r \\ nSec-WebSocket-Version: 13 \\ r \\ n \\ r \\ n "
) . encode ( " ascii " )
upstream . sendall ( request )
response = bytearray ( )
while b " \\ r \\ n \\ r \\ n " not in response and len ( response ) < 16384 :
chunk = upstream . recv ( 4096 )
if not chunk :
break
response . extend ( chunk )
header , separator , remainder = bytes ( response ) . partition ( b " \\ r \\ n \\ r \\ n " )
if not separator or not header . startswith ( b " HTTP/1.1 101 " ) :
upstream . close ( )
raise ConnectionError ( " private streaming STT rejected WebSocket upgrade " )
expected = base64 . b64encode ( hashlib . sha1 ( ( key + " 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 " ) . encode ( " ascii " ) ) . digest ( ) ) . decode ( " ascii " )
headers = { }
for line in header . split ( b " \\ r \\ n " ) [ 1 : ] :
name , marker , value = line . partition ( b " : " )
if marker :
headers [ name . decode ( " ascii " , " ignore " ) . strip ( ) . lower ( ) ] = value . decode ( " ascii " , " ignore " ) . strip ( )
if not secrets . compare_digest ( headers . get ( " sec-websocket-accept " , " " ) , expected ) :
upstream . close ( )
raise ConnectionError ( " private streaming STT returned an invalid handshake " )
upstream . settimeout ( 1 )
return upstream , remainder
def _atlas_ws_relay ( source , destination , stop , deadline ) :
transferred = 0
while not stop . is_set ( ) and time . monotonic ( ) < deadline :
try :
chunk = source . recv ( 16384 )
except ( _socket . timeout , TimeoutError ) :
continue
except OSError :
break
if not chunk :
break
transferred + = len ( chunk )
if transferred > ATLAS_VOICE_MAX_BYTES :
break
try :
destination . sendall ( chunk )
except OSError :
break
stop . set ( )
def _handle_atlas_stt_stream ( handler ) :
""" Bridge one authenticated same-origin browser WebSocket to private STT. """
target = _atlas_exact_stream_url ( " HERMES_WEBUI_ATLAS_STT_STREAM_URL " , ATLAS_STT_STREAM_URL )
if not target :
return bad ( handler , " Atlas streaming STT is not configured " , 503 )
if handler . headers . get ( " Upgrade " , " " ) . strip ( ) . lower ( ) != " websocket " :
return bad ( handler , " WebSocket upgrade required " , 426 )
if not _atlas_ws_authorized ( handler ) :
return bad ( handler , " WebSocket origin or CSRF validation failed " , 403 )
browser_key = handler . headers . get ( " Sec-WebSocket-Key " , " " ) . strip ( )
if handler . headers . get ( " Sec-WebSocket-Version " , " " ) . strip ( ) != " 13 " :
return bad ( handler , " WebSocket version 13 required " , 426 )
try :
decoded = base64 . b64decode ( browser_key , validate = True )
except Exception :
decoded = b " "
if len ( decoded ) != 16 :
return bad ( handler , " Invalid WebSocket key " , 400 )
try :
upstream , remainder = _atlas_ws_upstream_handshake ( target )
except Exception :
logger . exception ( " Atlas streaming STT connection failed " )
return bad ( handler , " Atlas streaming STT is unavailable " , 502 )
browser_accept = base64 . b64encode ( hashlib . sha1 ( ( browser_key + " 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 " ) . encode ( " ascii " ) ) . digest ( ) ) . decode ( " ascii " )
handler . send_response ( 101 , " Switching Protocols " )
handler . send_header ( " Upgrade " , " websocket " )
handler . send_header ( " Connection " , " Upgrade " )
handler . send_header ( " Sec-WebSocket-Accept " , browser_accept )
handler . send_header ( " Sec-WebSocket-Protocol " , ATLAS_VOICE_WS_PROTOCOL )
handler . end_headers ( )
handler . wfile . flush ( )
handler . close_connection = True
browser = handler . connection
browser . settimeout ( 1 )
stop = threading . Event ( )
deadline = time . monotonic ( ) + ATLAS_VOICE_DEADLINE_SECONDS
if remainder :
browser . sendall ( remainder )
reverse = threading . Thread (
target = _atlas_ws_relay ,
args = ( upstream , browser , stop , deadline ) ,
name = " atlas-stt-ws-upstream " ,
daemon = True ,
)
reverse . start ( )
try :
_atlas_ws_relay ( browser , upstream , stop , deadline )
finally :
stop . set ( )
for sock in ( upstream , browser ) :
try :
sock . shutdown ( _socket . SHUT_RDWR )
except OSError :
pass
upstream . close ( )
reverse . join ( timeout = 2 )
return True
def _tts_open ( req , * , timeout = 30 , opener_factory = None ) : ''' ,
)
replace_exact (
routes ,
''' def handle_get(handler, parsed) -> bool:
""" Handle all GET routes. Returns True if handled, False for 404. """
''' ,
''' def handle_get(handler, parsed) -> bool:
""" Handle all GET routes. Returns True if handled, False for 404. """
if parsed . path == " /api/voice/streaming/capability " :
return _handle_atlas_streaming_capability ( handler )
if parsed . path == " /api/transcribe/stream " :
return _handle_atlas_stt_stream ( handler )
''' ,
)
replace_exact (
routes ,
''' if parsed.path == " /api/transcribe " :
return handle_transcribe ( handler )
if parsed . path == " /api/tts " :
return _handle_tts ( handler , parsed )
''' ,
''' if parsed.path == " /api/transcribe " :
return handle_transcribe ( handler )
2026-08-23 22:13:52 -03:00
if parsed . path == " /api/voice/route-preflight " :
return _handle_atlas_voice_preflight ( handler )
2026-08-23 18:29:02 -03:00
if parsed . path == " /api/tts/stream " :
return _handle_atlas_tts_stream ( handler )
if parsed . path == " /api/tts " :
return _handle_tts ( handler , parsed )
''' ,
)
2026-08-24 13:58:35 -03:00
# The chat router's session-continuity poller renders whatever /api/session
# answers. Its 409 branch ("This session is unavailable to this account.")
# is only ever correct when the root-profile alias set is actually known —
# but list_profiles_api() is a hermes_cli subprocess call that fails
# transiently (cold pod after a roll, load spikes), and the pinned
# _is_root_profile() treats that failure as "not a root alias", flipping
# _profiles_match() to a false mismatch and painting the bogus banner over
# the input bar until the next successful listing. Two minimal grafts:
# answer alias checks from the last known alias set on listing failure, and
# never claim a default-vs-named mismatch while the alias set is unconfirmed.
profiles = ROOT / " api/profiles.py "
replace_exact (
profiles ,
''' except Exception:
logger . debug ( " Failed to list profiles for root-profile lookup " , exc_info = True )
return False
''' ,
''' except Exception:
logger . debug ( " Failed to list profiles for root-profile lookup " , exc_info = True )
# Atlas voice patch: a transient listing failure must not deny a name
# that was already confirmed as a root alias — answer from the last
# known alias set instead of failing the alias outright.
with _root_profile_name_cache_lock :
return name in _root_profile_name_cache
''' ,
)
replace_exact (
profiles ,
" def _is_root_profile(name: str) -> bool: \n " ,
''' def _root_profile_names_confirmed() -> bool:
""" True once list_profiles_api() has successfully populated the alias set.
Atlas voice patch : lets _profiles_match ( ) distinguish " these profiles are
definitely different " from " the root - alias equivalence could not be
checked yet " (cold cache right after a pod roll, or a failing hermes_cli
listing ) , which previously produced transient / api / session 409 s .
"""
with _root_profile_name_cache_lock :
return _root_profile_name_cache_loaded
def _is_root_profile ( name : str ) - > bool :
''' ,
)
replace_exact (
profiles ,
''' # Cross-alias the renamed root.
if _is_root_profile ( row ) and _is_root_profile ( active ) :
return True
return False
''' ,
''' # Cross-alias the renamed root.
if _is_root_profile ( row ) and _is_root_profile ( active ) :
return True
# Atlas voice patch: while the root alias set is unconfirmed (cold cache
# after a pod roll, hermes_cli listing failure) a pair involving the
# 'default' alias cannot be *proven* mismatched — the named side may be
# the renamed root. Fail open for that pair only: a mismatch between two
# named profiles is still denied, and exact scoping resumes with the
# first successful listing. This removes the transient /api/session 409
# that rendered a false "unavailable to this account" banner.
if " default " in ( row , active ) and not _root_profile_names_confirmed ( ) :
return True
return False
''' ,
)
2026-08-10 00:42:37 -03:00
marker = " # ── ElevenLabs TTS ────────────────────────────────────────────────── \n "
atlas = ''' # ── Atlas private Jetson TTS ─────────────────────────────────────────
if engine == " atlas " :
atlas_url = os . getenv ( " HERMES_WEBUI_ATLAS_TTS_URL " , " " ) . strip ( )
expected_url = " http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech "
if atlas_url != expected_url :
from api . helpers import bad as _bad
return _bad ( handler , " Atlas private TTS is not configured " , 503 )
speed = 1.0
if rate_str :
try :
speed = max ( 0.5 , min ( 2.0 , 1.0 + ( float ( rate_str . rstrip ( " % " ) ) / 100.0 ) ) )
except ValueError :
speed = 1.0
hermes(voice): continuous mic, barge stitching, 1.15x speech
Three conversational fixes for hands-free chat:
- The microphone now stays hot for the whole session: capture runs on
its own epoch, re-arms immediately after each utterance endpoints,
and keeps recording through transcribing/thinking/speaking - speech
is never lost to Hermes being busy. Speech onset during a response
cancels it through the live capture path (echo-guarded exactly like
the old monitor) without touching the running recorder.
- When the user talks over Hermes before any visible reply appeared,
the interrupted utterance and the follow-up are stitched into one
message (20s window), so the response addresses the whole thought.
- TTS speaks 15% faster by default (server-side length_scale, no pitch
shift), user-tunable via hermes-voice-tts-speed (0.5-2.0), honored on
streaming, WAV fallback and thinking-cue paths.
245 voice-lane tests pass; single getUserMedia site preserved;
Dockerfile grep guards verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
2026-08-24 06:27:38 -03:00
if isinstance ( data , dict ) and " speed " in data :
# Hands-free clients send an explicit validated speed; it wins
# over the legacy percentage rate string.
speed = _atlas_tts_speed ( data )
2026-08-21 11:52:49 +00:00
request_payload = {
2026-08-10 00:42:37 -03:00
" model " : " piper " ,
" input " : text ,
" speed " : speed ,
2026-08-21 11:52:49 +00:00
}
# Attach a language ONLY when the browser sent a plain allow-listed
# code. Omitting it is the fail-safe: the Jetson service then speaks
# its own English default, which is also what every partially rolled
# out combination of these components degrades to.
_atlas_language = _atlas_tts_language ( data )
if _atlas_language :
request_payload [ " language " ] = _atlas_language
request_body = json . dumps ( request_payload ) . encode ( " utf-8 " )
2026-08-10 00:42:37 -03:00
request = Request ( atlas_url , data = request_body , headers = {
" Content-Type " : " application/json " ,
" Accept " : " audio/wav " ,
} )
try :
with _tts_open (
request ,
timeout = 45 ,
opener_factory = lambda : build_opener ( ProxyHandler ( { } ) , _NoRedirectTtsHandler ( ) ) ,
) as response :
audio_data = _buffer_tts_audio_response ( response )
except Exception :
logger . exception ( " Atlas private TTS generation failed " )
from api . helpers import bad as _bad
return _bad ( handler , " Atlas private TTS generation failed " , 502 )
handler . send_response ( 200 )
handler . send_header ( " Content-Type " , " audio/wav " )
handler . send_header ( " Cache-Control " , " no-store " )
handler . send_header ( " Content-Length " , str ( len ( audio_data ) ) )
handler . end_headers ( )
try :
handler . wfile . write ( audio_data )
except ( BrokenPipeError , ConnectionResetError ) :
pass
return True
'''
replace_exact ( routes , marker , atlas + marker )