feat(hermes-voice): add premium conversation instrument #29

Merged
bstein merged 2 commits from hermes/chat-voice-conversation-instrument into main 2026-08-21 04:06:45 +00:00
10 changed files with 959 additions and 12 deletions

View File

@ -92,6 +92,7 @@ PY
COPY dockerfiles/hermes-webui-atlas-patch.py /tmp/hermes-webui-atlas-patch.py
COPY dockerfiles/hermes-webui-telegram-project-patch.py /tmp/hermes-webui-telegram-project-patch.py
COPY dockerfiles/hermes-webui-atlas-voice.js /opt/hermes-webui/static/atlas-voice.js
COPY dockerfiles/hermes-webui-atlas-voice.css /opt/hermes-webui/static/atlas-voice.css
COPY dockerfiles/hermes-webui-router-patch.py /tmp/hermes-webui-router-patch.py
COPY dockerfiles/hermes-webui-router.js /opt/hermes-webui/static/atlas-router.js
RUN /opt/hermes/.venv/bin/python /tmp/hermes-webui-atlas-patch.py
@ -109,6 +110,8 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
&& grep -Fq "capability.provider!=='local_command'" /opt/hermes-webui/static/atlas-voice.js \
&& grep -Fq 'prefers-reduced-motion: reduce' /opt/hermes-webui/static/atlas-voice.css \
&& grep -Fq 'id="voiceInstrumentStyles"' /opt/hermes-webui/static/index.html \
&& grep -Fq 'data-priority="maximum"' /opt/hermes-webui/static/index.html \
&& grep -Fq 'routing_priority:priority' /opt/hermes-webui/static/atlas-router.js \
&& grep -Fq "'atlas/auto/fast':'Automatic · Fast'" /opt/hermes-webui/static/atlas-router.js \

View File

@ -1,10 +1,11 @@
#!/usr/bin/env python3
"""Apply fail-closed Atlas voice integration patches to pinned Hermes WebUI."""
import os
from pathlib import Path
ROOT = Path("/opt/hermes-webui")
ROOT = Path(os.environ.get("HERMES_WEBUI_PATCH_ROOT", "/opt/hermes-webui"))
def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
@ -16,6 +17,13 @@ def replace_exact(path: Path, before: str, after: str, count: int = 1) -> None:
index = ROOT / "static/index.html"
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__">',
)
replace_exact(
index,
'<option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option>',
@ -26,6 +34,32 @@ replace_exact(
'<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>',
)
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>''',
)
ui = ROOT / "static/ui.js"
replace_exact(ui, "function _playEdgeTtsChunked(text, btn){", "function _playEdgeTtsChunked(text, btn, engineOverride){")

View File

@ -0,0 +1,336 @@
/* Private hands-free conversation instrument for the Atlas voice bridge. */
.voice-mode-bar {
--voice-accent: 72, 207, 204;
--voice-accent-secondary: 76, 164, 205;
position: relative;
isolation: isolate;
box-sizing: border-box;
min-height: 106px;
padding: 9px 12px 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
overflow: hidden;
border-bottom: 1px solid rgba(174, 192, 218, 0.1);
background:
radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.075), transparent 46%),
linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(4, 8, 18, 0.035));
}
.voice-mode-bar::before {
content: "";
position: absolute;
z-index: -1;
top: 0;
left: 18%;
right: 18%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(233, 244, 255, 0.16), transparent);
}
:root:not(.dark) .voice-mode-bar {
border-bottom-color: rgba(38, 55, 78, 0.1);
background:
radial-gradient(circle at 50% 38%, rgba(var(--voice-accent), 0.09), transparent 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(226, 232, 240, 0.12));
}
.voice-mode-indicator {
--voice-ripple-scale: 1.035;
--voice-ripple-opacity: 0.3;
position: relative;
width: 70px;
height: 70px;
flex: 0 0 70px;
border-radius: 50%;
transform: translateZ(0);
}
.voice-mode-indicator.listening {
--voice-accent: 69, 218, 207;
--voice-accent-secondary: 73, 169, 209;
}
.voice-mode-indicator.transcribing {
--voice-accent: 73, 193, 218;
--voice-accent-secondary: 93, 129, 224;
}
.voice-mode-indicator.thinking {
--voice-accent: 135, 111, 244;
--voice-accent-secondary: 81, 62, 190;
}
.voice-mode-indicator.speaking {
--voice-accent: 249, 169, 105;
--voice-accent-secondary: 236, 112, 137;
}
.voice-mode-indicator.error {
--voice-accent: 235, 135, 88;
--voice-accent-secondary: 198, 74, 78;
}
.voice-instrument-halo,
.voice-instrument-ripple,
.voice-instrument-orbit,
.voice-instrument-core {
position: absolute;
display: block;
box-sizing: border-box;
border-radius: 50%;
pointer-events: none;
}
.voice-instrument-halo {
inset: 2px;
opacity: 0;
background: radial-gradient(circle, rgba(var(--voice-accent), 0.2), rgba(var(--voice-accent), 0.05) 43%, transparent 70%);
filter: blur(4px);
}
.voice-instrument-ripple {
inset: 3px;
opacity: 0;
border: 1px solid rgba(var(--voice-accent), 0.56);
box-shadow: 0 0 12px rgba(var(--voice-accent), 0.13);
}
.voice-instrument-orbit {
inset: 2px;
opacity: 0;
border: 1px solid rgba(var(--voice-accent), 0.18);
box-shadow:
inset 0 0 8px rgba(var(--voice-accent), 0.06),
0 0 11px rgba(var(--voice-accent), 0.07);
}
.voice-instrument-orbit::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
border-top: 1.5px solid rgba(var(--voice-accent), 0.88);
border-right: 1px solid rgba(var(--voice-accent-secondary), 0.35);
border-bottom: 1px solid transparent;
border-left: 1px solid transparent;
}
.voice-instrument-orbit::after {
content: "";
position: absolute;
top: -2px;
left: 50%;
width: 5px;
height: 5px;
margin-left: -2.5px;
border-radius: 50%;
background: rgb(var(--voice-accent));
box-shadow:
0 0 5px rgba(var(--voice-accent), 0.92),
0 0 12px rgba(var(--voice-accent), 0.44);
}
.voice-instrument-core {
inset: 12px;
display: grid;
place-items: center;
color: rgba(242, 250, 255, 0.94);
border: 1px solid rgba(238, 247, 255, 0.2);
background:
radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.26), transparent 24%),
radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.36), transparent 64%),
linear-gradient(145deg, rgba(36, 45, 61, 0.96), rgba(10, 15, 27, 0.98));
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.22),
inset 0 -9px 18px rgba(0, 0, 0, 0.24),
0 4px 13px rgba(0, 0, 0, 0.25),
0 0 17px rgba(var(--voice-accent), 0.17);
}
:root:not(.dark) .voice-instrument-core {
color: rgba(31, 48, 66, 0.9);
border-color: rgba(255, 255, 255, 0.82);
background:
radial-gradient(circle at 38% 29%, rgba(255, 255, 255, 0.96), transparent 27%),
radial-gradient(circle at 50% 66%, rgba(var(--voice-accent), 0.2), transparent 65%),
linear-gradient(145deg, rgba(248, 250, 251, 0.98), rgba(216, 225, 231, 0.96));
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.94),
inset 0 -8px 17px rgba(64, 84, 100, 0.1),
0 4px 12px rgba(35, 53, 72, 0.15),
0 0 15px rgba(var(--voice-accent), 0.13);
}
.voice-instrument-symbol {
display: block;
width: 22px;
height: 22px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.32));
}
.voice-instrument-symbol svg {
display: block;
width: 100%;
height: 100%;
}
.voice-symbol {
display: none;
}
.voice-mode-indicator.listening .voice-symbol-listening,
.voice-mode-indicator.transcribing .voice-symbol-transcribing,
.voice-mode-indicator.thinking .voice-symbol-thinking,
.voice-mode-indicator.speaking .voice-symbol-speaking,
.voice-mode-indicator.error .voice-symbol-error {
display: inline;
}
.voice-mode-indicator.listening .voice-instrument-halo {
opacity: 0.62;
animation: voice-instrument-breathe 3.8s ease-in-out infinite;
}
.voice-mode-indicator.listening .voice-instrument-ripple {
opacity: var(--voice-ripple-opacity);
transform: scale(var(--voice-ripple-scale));
transition: transform 140ms ease-out, opacity 160ms ease-out;
}
.voice-mode-indicator.transcribing .voice-instrument-orbit {
opacity: 0.92;
animation: voice-instrument-orbit 3.4s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite;
}
.voice-mode-indicator.thinking .voice-instrument-orbit {
opacity: 0.96;
animation: voice-instrument-orbit 4.6s cubic-bezier(0.58, 0.12, 0.42, 0.88) infinite;
}
.voice-mode-indicator.thinking .voice-instrument-core {
animation: voice-instrument-core-breathe 5.7s ease-in-out infinite;
}
.voice-mode-indicator.speaking .voice-instrument-orbit {
opacity: 0.86;
transform: rotate(38deg);
}
.voice-mode-indicator.speaking.is-playing .voice-instrument-halo {
opacity: 0.66;
animation: voice-instrument-speaking-pulse 2.15s ease-out infinite;
}
.voice-mode-indicator.error .voice-instrument-orbit {
opacity: 0.9;
transform: rotate(-32deg);
border-color: rgba(var(--voice-accent), 0.44);
}
.voice-mode-indicator.error .voice-instrument-core {
color: rgba(255, 232, 219, 0.95);
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.2),
inset 0 -9px 18px rgba(0, 0, 0, 0.24),
0 4px 13px rgba(0, 0, 0, 0.25),
0 0 12px rgba(var(--voice-accent), 0.17);
}
.voice-mode-label {
position: relative;
z-index: 1;
min-height: 16px;
max-width: min(100%, 34rem);
overflow: hidden;
color: var(--text, #f4f6fb);
font-size: 12px;
font-weight: 650;
line-height: 1.35;
letter-spacing: 0.018em;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
:root:not(.dark) .voice-mode-label {
color: #263648;
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.8);
}
@keyframes voice-instrument-breathe {
0%, 100% { opacity: 0.38; transform: scale(0.94); }
50% { opacity: 0.72; transform: scale(1.06); }
}
@keyframes voice-instrument-orbit {
0% { transform: rotate(0deg); }
50% { transform: rotate(177deg); }
100% { transform: rotate(360deg); }
}
@keyframes voice-instrument-core-breathe {
0%, 100% { filter: saturate(0.96) brightness(0.98); }
50% { filter: saturate(1.08) brightness(1.06); }
}
@keyframes voice-instrument-speaking-pulse {
0% { opacity: 0.58; transform: scale(0.88); }
58% { opacity: 0.22; transform: scale(1.12); }
100% { opacity: 0; transform: scale(1.2); }
}
@media (max-width: 640px) {
.voice-mode-bar {
min-height: 94px;
padding: 7px 10px 8px;
gap: 4px;
}
.voice-mode-indicator {
width: 62px;
height: 62px;
flex-basis: 62px;
}
.voice-instrument-core {
inset: 11px;
}
.voice-instrument-symbol {
width: 20px;
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.voice-mode-bar *,
.voice-mode-bar *::before,
.voice-mode-bar *::after {
animation: none !important;
transition: none !important;
}
.voice-mode-indicator.listening .voice-instrument-halo {
opacity: 0.45;
transform: none;
}
.voice-mode-indicator.listening .voice-instrument-ripple {
opacity: 0.34;
transform: scale(1.035);
}
.voice-mode-indicator.transcribing .voice-instrument-orbit,
.voice-mode-indicator.thinking .voice-instrument-orbit {
transform: rotate(24deg);
}
.voice-mode-indicator.speaking.is-playing .voice-instrument-halo {
opacity: 0.42;
transform: scale(1.03);
}
}

View File

@ -19,6 +19,18 @@
let vadTimer=null;
let currentAudio=null;
let thinkingSession=null;
let errorTimer=null;
let visualInputLevel=0;
const reducedMotion=window.matchMedia?window.matchMedia('(prefers-reduced-motion: reduce)'):{matches:false};
const ERROR_VISIBLE_MS=3200;
const STATE_LABELS={
listening:'Listening',
transcribing:'Transcribing…',
thinking:'Thinking…',
speaking:'Speaking',
error:'Voice unavailable',
idle:'',
};
const originalAutoRead=window.autoReadLastAssistant;
const originalApplyPreference=window._applyVoiceModePref;
@ -29,8 +41,48 @@
function setState(next, customLabel){
state=next;
indicator.className='voice-mode-indicator '+next;
label.textContent=customLabel||(next==='listening'?'Listening…':next==='speaking'?'Speaking…':next==='thinking'?'Thinking…':'');
bar.style.display=active&&next!=='idle'?'':'none';
bar.dataset.voiceState=next;
bar.setAttribute('aria-busy',next==='transcribing'||next==='thinking'?'true':'false');
label.textContent=customLabel||STATE_LABELS[next]||'';
bar.style.display=(active&&next!=='idle')||next==='error'?'':'none';
resetInputLevel();
}
function resetInputLevel(){
visualInputLevel=0;
indicator.style.setProperty('--voice-ripple-scale','1.035');
indicator.style.setProperty('--voice-ripple-opacity','0.3');
}
function updateInputLevel(rms){
if(reducedMotion.matches||state!=='listening') return;
const target=Math.max(0,Math.min(1,(rms-0.01)/0.18));
visualInputLevel=(visualInputLevel*0.72)+(target*0.28);
indicator.style.setProperty('--voice-ripple-scale',(1.035+(visualInputLevel*0.16)).toFixed(3));
indicator.style.setProperty('--voice-ripple-opacity',(0.26+(visualInputLevel*0.48)).toFixed(3));
}
function clearErrorTimer(){
if(errorTimer){window.clearTimeout(errorTimer);errorTimer=null;}
}
function errorMessage(error, fallback){
return String((error&&error.message)||fallback||'Voice unavailable').trim();
}
function showUnavailable(message){
generation+=1;
active=false;
thinkingSession=null;
stopCapture();
stopPlayback();
modeBtn.classList.remove('active');
setState('error',message);
clearErrorTimer();
errorTimer=window.setTimeout(function(){
errorTimer=null;
if(!active&&state==='error'&&indicator.classList.contains('error')) setState('idle');
},ERROR_VISIBLE_MS);
}
function stopCapture(){
@ -47,17 +99,18 @@
if(!currentAudio) return;
try{currentAudio.pause();currentAudio.currentTime=0;}catch(_){ }
currentAudio=null;
indicator.classList.remove('is-playing');
}
function deactivate(showMessage){
generation+=1;
active=false;
state='idle';
thinkingSession=null;
clearErrorTimer();
stopCapture();
stopPlayback();
modeBtn.classList.remove('active');
bar.style.display='none';
setState('idle');
if(showMessage) toast('Hands-free voice mode off');
}
@ -80,7 +133,7 @@
async function transcribe(blob, token){
if(!active||token!==generation) return;
setState('thinking','Transcribing');
setState('transcribing');
const ext=(blob.type||'').indexOf('ogg')>=0?'ogg':'webm';
const form=new FormData();
form.append('file',new File([blob],'voice-input.'+ext,{type:blob.type||'audio/'+ext}));
@ -91,8 +144,9 @@
sendTranscript(payload.transcript,token);
}catch(error){
if(!active||token!==generation) return;
deactivate(false);
toast((error&&error.message)||'Private Whisper is unavailable');
const message=errorMessage(error,'Private Whisper is unavailable');
showUnavailable(message);
toast(message);
// If the browser supplies its own recognizer, hand control back to the
// upstream voice implementation until the Jetson becomes healthy again.
if(window.SpeechRecognition||window.webkitSpeechRecognition){
@ -166,6 +220,7 @@
energy+=normalized*normalized;
}
const rms=Math.sqrt(energy/samples.length);
updateInputLevel(rms);
const now=Date.now();
const speechThreshold=Math.max(0.04,noiseFloor*2.4+0.006);
const voiceNow=rms>speechThreshold;
@ -188,8 +243,9 @@
},100);
}catch(error){
if(!active||token!==generation) return;
deactivate(false);
toast((error&&error.message)||'Microphone permission is required');
const message=errorMessage(error,'Microphone permission is required');
showUnavailable(message);
toast(message);
}
}
@ -206,11 +262,14 @@
currentAudio=audio;
function cleanup(){
if(currentAudio===audio) currentAudio=null;
indicator.classList.remove('is-playing');
URL.revokeObjectURL(url);
}
audio.onended=function(){cleanup();resolve();};
audio.onerror=function(){cleanup();reject(new Error('Local speech playback failed'));};
audio.play().catch(function(error){cleanup();reject(error);});
audio.play().then(function(){
if(active&&token===generation&&currentAudio===audio) indicator.classList.add('is-playing');
}).catch(function(error){cleanup();reject(error);});
});
}
@ -251,7 +310,11 @@
await playBlob(blob,token);
}
}catch(error){
if(active&&token===generation) toast((error&&error.message)||'Local speech is unavailable');
if(active&&token===generation){
const message=errorMessage(error,'Local speech is unavailable');
setState('error',message);
toast(message);
}
}
restartSoon(token,450);
}
@ -260,6 +323,7 @@
generation+=1;
const token=generation;
active=true;
clearErrorTimer();
modeBtn.classList.add('active');
toast('Hands-free private voice mode on');
if(typeof window.stopTTS==='function') window.stopTTS();

View File

@ -0,0 +1,12 @@
# Hermes WebUI fixture provenance
These minimal files contain the exact patch-context fragments extracted from the
Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
- Image: `ghcr.io/nesquena/hermes-webui@sha256:a83a3893111dcb250e7aa7aa657d3d6f4570b0e2fd00d9b7569246fc5e7339b2`
- Version: `0.52.181`
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
The fixture stays intentionally narrow, but tests execute the shipped
`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic.

View File

@ -0,0 +1,3 @@
def tts(handler, engine):
# ── ElevenLabs TTS ──────────────────────────────────────────────────
return False

View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
</head>
<body>
<select id="settingsTtsEngine"><option value="browser">Browser speech synthesis</option><option value="edge">Edge TTS (server)</option></select>
<div class="composer-box" id="composerBox">
<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>
<textarea id="msg" rows="1" placeholder="Message Hermes…"></textarea>
<button class="icon-btn voice-mode-btn has-tooltip" id="btnVoiceMode" data-tooltip="Voice mode" data-i18n-title="voice_mode_toggle" style="display:none"></button>
</div>
<script src="static/boot.js?v=__WEBUI_VERSION__" defer></script>
</body>
</html>

View File

@ -0,0 +1,13 @@
function _playEdgeTtsChunked(text, btn){
return fetch('/api/tts',{body:JSON.stringify({text:chunk, voice:voice, rate:rate, pitch:pitch})});
}
function speakSelected(clean, btn, engine){
if(engine==='edge'){
_playEdgeTtsChunked(clean, btn);
}
}
function speakAutomatically(clean, engine){
if(engine==='edge'){
_playEdgeTtsChunked(clean, null);
}
}

View File

@ -0,0 +1,356 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
class StyleDeclaration {
constructor() {
this.values = new Map();
this.display = '';
}
setProperty(name, value) { this.values.set(name, String(value)); }
removeProperty(name) { this.values.delete(name); }
getPropertyValue(name) { return this.values.get(name) || ''; }
}
class Element {
constructor(id) {
this.id = id;
this.className = '';
this.textContent = '';
this.value = '';
this.dataset = {};
this.style = new StyleDeclaration();
this.attributes = new Map();
this.listeners = new Map();
this.children = [];
this.firstChild = null;
}
get classList() {
const element = this;
return {
add(name) {
const names = new Set(element.className.split(/\s+/).filter(Boolean));
names.add(name);
element.className = [...names].join(' ');
},
remove(name) {
element.className = element.className.split(/\s+/).filter(value => value && value !== name).join(' ');
},
contains(name) { return element.className.split(/\s+/).includes(name); },
};
}
addEventListener(type, callback) { this.listeners.set(type, callback); }
removeEventListener(type, callback) {
if (this.listeners.get(type) === callback) this.listeners.delete(type);
}
setAttribute(name, value) { this.attributes.set(name, String(value)); }
getAttribute(name) { return this.attributes.get(name) || null; }
insertBefore(child) {
this.children.unshift(child);
this.firstChild = this.children[0];
}
querySelector(selector) {
if (selector === 'option[value="atlas"]') {
return this.children.find(child => child.value === 'atlas') || null;
}
return null;
}
click() {
const callback = this.listeners.get('click');
if (callback) callback({preventDefault() {}, stopImmediatePropagation() {}});
}
}
function flush() {
return new Promise(resolve => setImmediate(resolve));
}
async function boot(scriptPath, reduced) {
const elements = new Map();
for (const id of ['btnVoiceMode', 'voiceModeBar', 'voiceModeIndicator', 'voiceModeLabel', 'msg', 'settingsTtsEngine', 'voiceInstrumentStyles']) {
elements.set(id, new Element(id));
}
const modeButton = elements.get('btnVoiceMode');
const bar = elements.get('voiceModeBar');
const indicator = elements.get('voiceModeIndicator');
const label = elements.get('voiceModeLabel');
const styleLink = elements.get('voiceInstrumentStyles');
styleLink.setAttribute('href', 'static/atlas-voice.css?v=test');
bar.style.display = 'none';
let now = 1000;
let nextTimer = 1;
const intervals = new Map();
const timeouts = new Map();
const captures = [];
const recorders = [];
const analysers = [];
const assistantRows = [];
const toasts = [];
const reducedMotion = {matches: reduced, addEventListener() {}, removeEventListener() {}};
let rejectNextCapture = false;
let transcriptResolve;
let sent = 0;
let lastAudio = null;
class FakeDate extends Date {
static now() { return now; }
}
class FakeAnalyser {
constructor() { this.fftSize = 0; this.level = 0; }
getByteTimeDomainData(samples) {
const sample = 128 + Math.round(this.level * 128);
samples.fill(sample);
}
}
class FakeAudioContext {
createAnalyser() {
const analyser = new FakeAnalyser();
analysers.push(analyser);
return analyser;
}
createBiquadFilter() { return {type: '', frequency: {value: 0}, Q: {value: 0}, connect() {}}; }
createMediaStreamSource() { return {connect() {}}; }
close() { return Promise.resolve(); }
}
class FakeMediaRecorder {
static isTypeSupported() { return true; }
constructor(stream) {
this.stream = stream;
this.state = 'inactive';
this.ondataavailable = null;
this.onstop = null;
recorders.push(this);
}
start() { this.state = 'recording'; }
stop() {
if (this.state === 'inactive') return;
this.state = 'inactive';
if (this.onstop) queueMicrotask(() => this.onstop());
}
}
class FakeAudio {
constructor() {
this.currentTime = 0;
this.onended = null;
this.onerror = null;
this.paused = false;
lastAudio = this;
}
play() { this.paused = false; return Promise.resolve(); }
pause() { this.paused = true; }
finish() { if (this.onended) this.onended(); }
}
const document = {
getElementById(id) { return elements.get(id) || null; },
createElement() { return new Element('created'); },
querySelectorAll(selector) {
assert.equal(selector, '.msg-row[data-role="assistant"], .assistant-segment[data-raw-text]');
return assistantRows;
},
};
const localValues = new Map();
const localStorage = {
getItem(key) { return localValues.has(key) ? localValues.get(key) : null; },
setItem(key, value) { localValues.set(key, String(value)); },
};
const stream = {getTracks() { return [{stop() {}}]; }};
const navigator = {
mediaDevices: {
getSupportedConstraints() { return {}; },
async getUserMedia() {
if (rejectNextCapture) {
rejectNextCapture = false;
throw new Error('Microphone unavailable');
}
captures.push(stream);
return stream;
},
},
};
async function fetch(url) {
if (url === '/api/transcribe/capability') {
return {ok: true, json: async () => ({available: true, provider: 'local_command'})};
}
if (url === '/api/transcribe') {
return new Promise(resolve => { transcriptResolve = resolve; });
}
if (url === '/api/tts') {
return {ok: true, blob: async () => new Blob(['wave'], {type: 'audio/wav'})};
}
throw new Error(`unexpected fetch: ${url}`);
}
const window = {
MediaRecorder: FakeMediaRecorder,
AudioContext: FakeAudioContext,
setInterval(callback) {
const id = nextTimer++;
intervals.set(id, callback);
return id;
},
clearInterval(id) { intervals.delete(id); },
setTimeout(callback, delay) {
const id = nextTimer++;
timeouts.set(id, {callback, delay});
return id;
},
clearTimeout(id) { timeouts.delete(id); },
matchMedia(query) {
assert.equal(query, '(prefers-reduced-motion: reduce)');
return reducedMotion;
},
showToast(message) { toasts.push(message); },
autoResize() {},
send() { sent += 1; },
stopTTS() {},
_splitForTTS(text) { return [text]; },
_stripForTTS(text) { return text; },
URL: {createObjectURL() { return 'blob:voice'; }, revokeObjectURL() {}},
};
const context = {
Audio: FakeAudio,
Blob,
Date: FakeDate,
File,
FormData,
MediaRecorder: FakeMediaRecorder,
URL: window.URL,
Uint8Array,
clearInterval: window.clearInterval,
console,
document,
fetch,
localStorage,
navigator,
queueMicrotask,
S: {busy: false, session: {session_id: 'session-1'}},
window,
};
window.window = window;
window.document = document;
window.fetch = fetch;
window.localStorage = localStorage;
window.navigator = navigator;
Object.assign(window, {Blob, Date: FakeDate, File, FormData, URL: window.URL, Uint8Array});
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), context, {filename: scriptPath});
await flush();
await flush();
return {
elements, modeButton, bar, indicator, label, styleLink, intervals, timeouts, window,
captures, recorders, analysers, assistantRows, toasts, reducedMotion,
get sent() { return sent; },
get lastAudio() { return lastAudio; },
set now(value) { now = value; },
rejectCapture() { rejectNextCapture = true; },
resolveTranscript(payload) {
assert.ok(transcriptResolve, 'transcription request was not started');
transcriptResolve({ok: true, json: async () => payload});
},
runIntervals() { for (const callback of [...intervals.values()]) callback(); },
runTimeout(delay) {
const match = [...timeouts].find(([, timer]) => timer.delay === delay);
assert.ok(match, `timer ${delay}ms was not scheduled`);
timeouts.delete(match[0]);
match[1].callback();
},
};
}
async function normalMotionContract(scriptPath) {
const probe = await boot(scriptPath, false);
const originalStyleLink = probe.styleLink;
probe.modeButton.click();
assert.match(probe.indicator.className, /\blistening\b/);
assert.equal(probe.label.textContent, 'Listening');
assert.equal(probe.bar.style.display, '');
await flush();
assert.equal(probe.captures.length, 1);
probe.analysers[0].level = 0.3;
probe.runIntervals();
probe.runIntervals();
probe.runIntervals();
probe.recorders[0].ondataavailable({data: new Blob(['speech'], {type: 'audio/webm'})});
probe.now = 4000;
probe.analysers[0].level = 0;
probe.runIntervals();
await flush();
assert.match(probe.indicator.className, /\btranscribing\b/);
assert.equal(probe.label.textContent, 'Transcribing…');
probe.resolveTranscript({transcript: 'Hello Hermes'});
await flush();
await flush();
assert.match(probe.indicator.className, /\bthinking\b/);
assert.equal(probe.label.textContent, 'Thinking…');
assert.equal(probe.sent, 1);
probe.assistantRows.push({dataset: {rawText: 'A calm answer.'}});
const capturesBeforeSpeech = probe.captures.length;
probe.window.autoReadLastAssistant();
assert.match(probe.indicator.className, /\bspeaking\b/);
assert.equal(probe.label.textContent, 'Speaking');
assert.equal(probe.captures.length, capturesBeforeSpeech);
await flush();
await flush();
assert.match(probe.indicator.className, /\bis-playing\b/);
assert.equal(probe.captures.length, capturesBeforeSpeech);
probe.lastAudio.finish();
await flush();
assert.doesNotMatch(probe.indicator.className, /\bis-playing\b/);
return {probe, originalStyleLink, capturesBeforeSpeech};
}
async function main() {
const scriptPath = process.argv[2];
assert.ok(scriptPath, 'usage: node hermes_voice_instrument_probe.js <atlas-voice.js>');
const first = await normalMotionContract(scriptPath);
const {probe, originalStyleLink, capturesBeforeSpeech} = first;
assert.equal(probe.captures.length, capturesBeforeSpeech);
assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink);
probe.modeButton.click();
assert.equal(probe.bar.style.display, 'none');
assert.match(probe.indicator.className, /\bidle\b/);
probe.modeButton.click();
await flush();
probe.modeButton.click();
assert.equal(probe.bar.style.display, 'none');
assert.strictEqual(probe.elements.get('voiceInstrumentStyles'), originalStyleLink);
const reduced = await boot(scriptPath, true);
reduced.modeButton.click();
await flush();
const initialScale = reduced.indicator.style.getPropertyValue('--voice-ripple-scale');
reduced.analysers[0].level = 0.5;
reduced.runIntervals();
assert.equal(reduced.indicator.style.getPropertyValue('--voice-ripple-scale'), initialScale);
reduced.modeButton.click();
const unavailable = await boot(scriptPath, false);
unavailable.rejectCapture();
unavailable.modeButton.click();
await flush();
await flush();
assert.match(unavailable.indicator.className, /\berror\b/);
assert.equal(unavailable.label.textContent, 'Microphone unavailable');
assert.equal(unavailable.bar.style.display, '');
unavailable.runTimeout(3200);
assert.equal(unavailable.bar.style.display, 'none');
assert.match(unavailable.indicator.className, /\bidle\b/);
process.stdout.write('voice instrument DOM contract passed\n');
}
main().catch(error => {
console.error(error.stack || error);
process.exitCode = 1;
});

View File

@ -0,0 +1,108 @@
"""Shipped patch and DOM contracts for the hands-free conversation instrument."""
from __future__ import annotations
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
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"
def _patched_fixture(tmp_path: Path) -> Path:
target = tmp_path / "hermes-webui"
shutil.copytree(FIXTURE, target)
env = os.environ.copy()
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
subprocess.run(
[sys.executable, str(PATCHER)],
cwd=ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
return target
def test_real_upstream_fixture_receives_visual_instrument_contract(tmp_path: Path):
"""Apply the production patcher to exact fragments from pinned WebUI 0.52.181."""
target = _patched_fixture(tmp_path)
index = (target / "static/index.html").read_text(encoding="utf-8")
assert index.count('id="voiceInstrumentStyles"') == 1
assert (
'href="static/atlas-voice.css?v=__WEBUI_VERSION__"' in index
)
assert 'role="status"' in index
assert 'aria-live="polite"' in index
assert 'aria-atomic="true"' in index
for layer in (
"voice-instrument-halo",
"voice-instrument-ripple",
"voice-instrument-orbit",
"voice-instrument-core",
"voice-instrument-symbol",
):
assert layer in index
assert '<button' not in index[index.index('id="voiceModeBar"') : index.index('<textarea')]
def test_visual_states_have_distinct_layers_finite_error_and_reduced_motion():
css = VOICE_CSS.read_text(encoding="utf-8")
for state in ("listening", "transcribing", "thinking", "speaking", "error"):
assert f".voice-mode-indicator.{state}" in css
for animation in (
"voice-instrument-breathe",
"voice-instrument-orbit",
"voice-instrument-speaking-pulse",
):
assert f"@keyframes {animation}" in css
assert (
".voice-mode-indicator.speaking.is-playing .voice-instrument-halo"
in css
)
error_rules = "\n".join(
match.group(0)
for match in re.finditer(r"[^{}]*\.error[^{}]*\{[^{}]*\}", css)
)
assert "animation:" not in error_rules
assert "@media (prefers-reduced-motion: reduce)" in css
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
assert "animation: none !important" in reduced
assert "transition: none !important" in reduced
def test_dom_probe_exercises_actual_injected_voice_script():
result = subprocess.run(
["node", str(DOM_PROBE), str(VOICE_JS)],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
assert result.stdout.strip() == "voice instrument DOM contract passed"
def test_visual_slice_preserves_private_voice_request_and_capture_contract():
script = VOICE_JS.read_text(encoding="utf-8")
assert script.count("navigator.mediaDevices.getUserMedia(") == 1
assert "form.append('file',new File([blob],'voice-input.'+ext" in script
assert "fetch('/api/transcribe',{method:'POST',body:form})" in script
assert "body:JSON.stringify({text:chunk,engine:'atlas'})" in script
assert "speakResponse(generation)" in script
assert "window._voiceModeImmediateSend" in script
assert "mute" not in script.lower()
assert "language" not in script.lower()